Making a CLI using Golang and Cobra

Posted on Jan 5, 2023

Introduction

In this blog post, we'll delve into the fascinating world of Command-Line Interfaces (CLI). We will walk through the process of creating your own CLI tool using Golang and the Cobra library. Whether you are a developer looking to streamline your workflow, or simply a technology enthusiast interested in enhancing your terminal experience, this guide is for you.

Why Go and Cobra?

Go, or Golang, is an open-source programming language that makes it easy to build fast and reliable software. It's well-suited for creating CLI tools due to its simplicity and efficiency.

Cobra, on the other hand, is a library that simplifies CLI development in Go. It helps you create a fully functional CLI with proper command structure, flag parsing, and auto-generated help text, making your CLI tool user-friendly and maintainable.

Setting up Your Go Environment

Before we get started, you'll need to set up your Go environment. If you haven't done so yet, you can download and install Go from the official website. Once installed, you can verify the installation by running `go version` in your terminal.

$ go version

Installing Cobra

Next, we'll need to install Cobra. You can do this easily using go get.

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

Installing Viper

Viper complements Cobra by offering configuration management. It can read from JSON, TOML, YAML, and other configuration file formats, as well as environment variables and command-line flags, making it incredibly flexible for any project. Lets install Viper so we can use a `.env` file later.

$ go get github.com/spf13/viper

Creating a Basic CLI Structure

Let's initialize our Cobra application.

$ cobra init mycli

This will create a basic CLI structure for us to build upon. You'll find that Cobra has generated several files, including a `main.go` and a cmd folder containing `root.go`. Here is the folder structure below:


mycli/
|-- cmd/
|   |-- root.go
|-- main.go
|-- go.mod
|-- go.sum

Adding New Commands Using Cobra CLI

Cobra comes with its own CLI tool that makes it super easy to add new commands to your application. Let's say you want to add a new command called `sayhello` to your CLI tool. You can use the following command to generate the appropriate Go file and boilerplate code:

$ cobra add sayhello

This will create a new file named sayhello.go inside your cmd folder. Open this file, and you will see a basic structure that looks something like this:


// sayhello.go

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

// sayhelloCmd represents the sayhello command
var sayhelloCmd = &cobra.Command{
	Use:   "sayhello",
	Short: "A brief description of your command",
	Long:  `A longer description that spans multiple lines.`,
	Run: func(cmd *cobra.Command, args []string) {
		// Your custom code here
		fmt.Println("Hello, world!")
	},
}

func init() {
	rootCmd.AddCommand(sayhelloCmd)

	// Here you will define your flags and configuration settings.

	// Cobra supports Persistent Flags which will work for this command
	// and all subcommands, e.g.:
	// sayhelloCmd.PersistentFlags().String("foo", "", "A help for foo")

	// Cobra supports local flags which will only run when this command
	// is called directly, e.g.:
	// sayhelloCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
}

You can edit the Short and Long descriptions to better describe what your sayhello command does. The Run function is where you place the code that will be executed when the sayhello command is called.

For example, if you want to make the sayhello command print "Hello, [NAME]", where [NAME] is an optional argument, you can modify the Run function like this:


Run: func(cmd *cobra.Command, args []string) {
	if len(args) > 0 {
		fmt.Printf("Hello, %s!\n", args[0])
	} else {
		fmt.Println("Hello, world!")
	}
},

With this setup, running `./mycli sayhello` will print "Hello, world!", and running `./mycli sayhello John` will print "Hello, John!".

Let's take a look at that folder structure again.


mycli/
|-- cmd/
|   |-- root.go
|   |-- sayhello.go
|-- main.go
|-- go.mod
|-- go.sum

The way we will structure our project will be like this:


mycli/
|-- cmd/
|   |-- root.go
|   |-- sayhello.go
|   |-- task/
|   |   |-- add.go
|   |   |-- list.go
|   |   |-- delete.go
|-- pkg/
|   |-- database/
|   |   |-- database.go
|   |   |-- itemscanner.go
|-- main.go
|-- go.mod
|-- go.sum

Let's break it down.

The task subfolder within the cmd folder has a group of related subcommands: add, list, and delete. All focused on tasks.

task/ Folder

This folder contains the subcommands related to tasks. Each Go file corresponds to a different subcommand:

How These Files Interact

Each of these Go files should define a cobra.Command object that implements the subcommand's functionality and registers it with a parent command, probably defined in a file at a higher level in the cmd folder. This parent command is often the root command for a CLI application, but it could also be a task command defined in another file, say task.go, that serves as a parent only for these task-related subcommands.

In a typical Cobra app, the parent-child relationship among commands is defined in the init functions within these command files, usually at the bottom of each file. The parent command will add each of these subcommands using its AddCommand() method.

For example, in add.go:


func init() {
	taskCmd.AddCommand(addCmd)
}				

Linking with pkg/

The pkg/ folder contains helper functions and types that these subcommands use. For example, database.go contains methods for adding, listing, and deleting tasks in a database, while itemscanner.go has utility functions for scanning and validating task descriptions or other input.

By structuring your application in this way, you make it easier to keep a separation of concerns. Command-line parsing logic resides in the cmd/ folder, while the actual business logic resides in pkg/.

This structure will also make it easier to write unit tests, share common code among commands, and manage dependencies.

Adding task command

So let's go ahead and make all the directories and files that we need so we can write some code.

Let's make the directories first. Make sure to navigate to your projects root folder and type in your terminal:


$ mkdir cmd/task, ./pkg/database		

Now that our directories are made let's go ahead and make our helper files:


$ New-Item ./pkg/database/database.go, ./pkg/database/itemscanner.go		

Okay now let's use the `cobra-cli` command to generate new command files for us:


$ cobra-cli add task    // Generates the task command


$ cobra-cli add add -p "TaskCmd"   // Generates the add command a subcommand for task


$ cobra-cli add list -p "TaskCmd"  // Generates the list command a subcommand for task


$ cobra-cli add delete -p "TaskCmd"	// Generates the delete command a subcommand for task

You'll notice that the last three commands have a `-p` flag. This is used to assign a parent command to the newly added command. In this case, we want to assign the "add", "list", and "delete" command to the "task" command. All commands have a default parent of rootCmd if not specified.

These commands by default get placed in the `cmd` directory. We will want to move them all into our `task` directory:


$ move-item ./cmd/task.go, ./cmd/add.go, ./cmd/list.go, ./cmd/delete.go ./cmd/task

The first thing we will want to do is open our `root.go` file and make some changes:


package cmd

import (
	"fmt"
	"mycli/cmd/task"
	"os"

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

// Generated code ...

func addSubCommand() {
	rootCmd.AddCommand(task.TaskCmd)
}

func init() {
	cobra.OnInitialize(initConfig)
	addSubCommand()
	// Here you will define your flags and configuration settings.
	// Cobra supports persistent flags, which, if defined here,
	// will be global for your application.
	
	rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.maxx.yaml)")

	// Cobra also supports local flags, which will only run
	// when this action is called directly.
	rootCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
}

// Generated code ...

In our `root.go` we added an import to for our task command `"mycli/cmd/task"`. We also added a function `addSubCommand`. Our `addSubCommand` function adds our task command to the root command so that we can use it. And lastly we call our function in the `init` function so that it gets added upon initialization.

Next let's edit our `task.go` file:


package task

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

// taskCmd represents the task command
var TaskCmd = &cobra.Command{
	Use:   "task",
	Short: "Task/todo manager",
	Long:  ``,
	Run: func(cmd *cobra.Command, args []string) {
		cmd.Help()
	},
}

func init() {}

Here in we set the package name to the `task` package. Then we capitalize our `TaskCmd` variable so that we can access it throughout our application. And lastly we replace everything in our run function with `cmd.Help()`. This is so cobra can generate a help blurb for our task command.

Now we will modify the subcommands related to the task command which are `add.go`, `list.go`, and `delete.go`. We will pretty much do the same thing to each file. Add to the "task" package, delete boiler plate in "Run" function, and add the subcommand to our "TaskCmd" with `TaskCmd.AddCommand({cmdToAdd})`

add.go


package task

import (
	"fmt""

	"github.com/spf13/cobra"
)

// addCmd represents the add command
var addCmd = &cobra.Command{
	Use:   "add",
	Short: "Adds a new task",
	Run: func(cmd *cobra.Command, args []string) {
		// code goes here
	},
}

func init() {
	TaskCmd.AddCommand(addCmd)
}

list.go


package task

import (
	"fmt"

	"github.com/spf13/cobra"
)

// listCmd represents the list command
var listCmd = &cobra.Command{
	Use:   "list",
	Short: "Lists all tasks",
	Run: func(cmd *cobra.Command, args []string) {
		
	},
}

func init() {
	TaskCmd.AddCommand(listCmd)
}

delete.go


package task

import (
	"fmt"

	"github.com/spf13/cobra"
)

// deleteCmd represents the delete command
var deleteCmd = &cobra.Command{
	Use:   "delete",
	Short: "Deletes a task",
	Run: func(cmd *cobra.Command, args []string) {
		
	},
}

func init() {
	TaskCmd.AddCommand(deleteCmd)
}

Now that all that is done it's time to add the database.

Adding SQLite Database

SQLite is a lightweight, serverless, and self-contained SQL database engine that's easy to set up and use. To work with SQLite in Go (Golang), you'll need to use a driver that allows the Go programming language to interact with SQLite databases. The most commonly used driver is go-sqlite3.

Install the go-sqlite3 package:


$ go get -u github.com/mattn/go-sqlite3

Once that is installed let's open up our `itemscanner.go` file. Here we will define objects that we will be using for our application.


package database

import "database/sql"

// ItemScanner interface for scanning from an sql.Rows object
type ItemScanner interface {
	ScanRow(*sql.Rows) error
	NewInstance() ItemScanner
}

// Todo defines a task
type Todo struct {
	ID        int    `json:"id"`
	Completed bool   `json:"completed"`
	Task      string `json:"task"`
}

// ScanRow implements the ItemScanner interface for todos
func (td *Todo) ScanRow(rows *sql.Rows) error {
	return rows.Scan(&td.ID, &td.Completed, &td.Task)
}

// NewInstance creates a new Todo object
func (t *Todo) NewInstance() ItemScanner {
	return &Todo{}
}

The ItemScanner interface defines two methods:

`ScanRow(*sql.Rows) error`: This method expects an `*sql.Rows` object and is supposed to scan a row from that object into the struct implementing the interface. If the scan operation fails, it will return an error.

`NewInstance() ItemScanner`: This method is intended to create a new instance of the struct that implements the `ItemScanner` interface. This can be useful for methods that need to handle generic types.

How It's Used with Todo

The `Todo` struct implements the `ItemScanner` interface by defining `ScanRow` and `NewInstance` methods. `ScanRow` scans the values of a row from the `*sql.Rows` object into a `Todo` object's fields.

`NewInstance` returns a new `Todo` object, which also fulfills the `ItemScanner` interface.

The purpose of using this interface is to write generic database functions that can work with different types of items (like todos, notes, files, etc.) as long as they implement the `ItemScanner` interface

Now time for some database code. let's edit our `database.go`.


package database

import (
	"database/sql"
	"fmt"
	"log"

	_ "github.com/mattn/go-sqlite3"
)

type DataStorage struct {
	dbPath string
}

func NewDataStorage(dbPath string) *DataStorage {
	return &DataStorage{dbPath: dbPath}
}

func (ds *DataStorage) openDB() *sql.DB {
	db, err := sql.Open("sqlite3", ds.dbPath)
	if err != nil {
		log.Fatal(err)
	}
	return db
}

func (ds *DataStorage) Exec(query string, args ...interface{}) {
	db := ds.openDB()
	defer db.Close()
	_, err := db.Exec(query, args...)
	if err != nil {
		log.Fatal(err)
	}
}

func (ds *DataStorage) InitializeTables() {
	tableQueries := []string{
		"CREATE TABLE IF NOT EXISTS todos (id INTEGER PRIMARY KEY, completed BOOL, task TEXT)",
	}

	for _, query := range tableQueries {
		ds.Exec(query)
	}
}

func (ds *DataStorage) InsertData(tableName string, fields string, values ...interface{}) {
	placeholder := ""
	for i := 0; i < len(values); i++ {
		placeholder += "?"
		if i < len(values)-1 {
			placeholder += ", "
		}
	}
	query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s);", tableName, fields, placeholder)
	ds.Exec(query, values...)
}

func (ds *DataStorage) DeleteData(tableName string, condition string, args ...interface{}) {
	query := fmt.Sprintf("DELETE FROM %s WHERE %s;", tableName, condition)
	ds.Exec(query, args...)
}

// ListItems generic function to list items from a table
func (ds *DataStorage) ListItems(tableName string, scanTarget ItemScanner) ([]ItemScanner, error) {
	var items []ItemScanner

	// Open the DB connection
	db := ds.openDB()
	defer db.Close()

	// Prepare SQL query
	query := fmt.Sprintf("SELECT * FROM %s", tableName)

	rows, err := db.Query(query)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	for rows.Next() {
		// Make a new instance of the scanTarget type
		newItem := scanTarget.NewInstance()

		if err := newItem.ScanRow(rows); err != nil {
			return nil, err
		}
		items = append(items, newItem)
	}

	return items, nil
}

DataStorage Struct

Defines a type DataStorage that will handle database operations. Contains a single field dbPath, the path to the SQLite3 database file.

NewDataStorage Function

Initializes a new DataStorage instance with the given database path.

openDB Method

Opens a new database connection and returns an *sql.DB object. Exits the program if an error occurs.

Exec Method

Executes a SQL query with optional arguments. Uses db.Exec to run the query and check for errors.

InitializeTables Method

Initializes the database tables if they don't exist.

InsertData Method

Inserts data into a table. It constructs a query string dynamically based on the provided table name, fields, and values.

DeleteData Method

Deletes data from a table based on a condition.

ListItems Method

Fetches all rows from a table and scans them into a list of ItemScanner objects. This function is generic and works with any struct that implements the ItemScanner interface.

Wrapping up

Finally lets' go back to our task subcommands and make some modifications to them:

add.go


package task

import (
	"fmt"
	"strings"
	"mycli/pkg/database"

	"github.com/spf13/cobra"
)

// addCmd represents the add command
var addCmd = &cobra.Command{
	Use:   "add",
	Short: "Adds a new task",
	Run: func(cmd *cobra.Command, args []string) {
		database.DataStorage = database.NewDataStorage("path/to/database.db")
		joined := strings.Join(args, " ")

		database.DataStorage.InsertData("todos", "completed, task", false, joined)
		fmt.Printf("Added a new task: \"%s\"\n", joined)
	},
}

func init() {
	TaskCmd.AddCommand(addCmd)
}

list.go


package task

import (
	"fmt"
	"mycli/pkg/database"

	"github.com/spf13/cobra"
)

// listCmd represents the list command
var listCmd = &cobra.Command{
	Use:   "list",
	Short: "Lists all tasks",
	Run: func(cmd *cobra.Command, args []string) {
		database.DataStorage = database.NewDataStorage("path/to/database.db")

		var items []database.ItemScanner
		var err error

		items, err = database.DataStorage.ListItems("todos", &database.Todo{})
		logError(err)

		fmt.Println(len(items))

		for _, item := range items {
			switch v := item.(type) {
			case *database.Todo:
				fmt.Printf("ID: %d, Completed: %v, Task: %v\n", v.ID, v.Completed, v.Task)
			}
		}
	},
}

func logError(err error) {
	if err != nil {
		fmt.Println(err)
	}
}

func init() {
	TaskCmd.AddCommand(listCmd)
}

delete.go


package task

import (
	"fmt"
	"os"
	"strconv"
	"mycli/pkg/database"
	
	"github.com/spf13/cobra"
)

// deleteCmd represents the delete command
var deleteCmd = &cobra.Command{
	Use:   "delete",
	Short: "Deletes a task",
	Run: func(cmd *cobra.Command, args []string) {
		database.DataStorage = database.NewDataStorage("path/to/database.db")
		if len(args) == 0 {
			fmt.Println("Please provide a task id to delete.")
			return
		}

		taskID, err := strconv.Atoi(args[0])
		if err != nil {
			fmt.Println("Invalid task number:", err)
			return
		}

		database.DataStorage.DeleteData("todos", "id = ?", taskID)
	},
}

func init() {
	TaskCmd.AddCommand(deleteCmd)
}

add Command (Defined in add.go)

What it Does:

Adds a new task to the database.

How it Works:

Initializes a new database storage (DataStorage) object from a file path (path/to/database.db). Joins all arguments passed to the command (args) into a single string. Inserts the task into a table called todos in the database, marking it as incomplete (completed = false). Prints a message confirming the addition of the new task.

list Command (Defined in list.go)

What it Does:

Lists all the tasks stored in the database.

How it Works:

Initializes a new database storage (DataStorage) object from the same file path. Fetches all the items from the todos table and stores them in an items slice. Loops through each item and prints out its ID, completion status, and the task description.

delete Command (Defined in delete.go)

What it Does:

Deletes a task based on its ID.

How it Works:

Initializes a new database storage (DataStorage) object. Checks if the ID argument is provided; if not, prints an error message. Converts the ID argument (args[0]) to an integer. Deletes the task with the specified ID from the todos table in the database.

Using our CLI

Before running any of these commands, make sure to build your Cobra CLI application. Navigate to your project directory and run:


$ go build

This will create an executable, likely named mycli (or whatever your project's root directory is named), which you can then use to execute your subcommands.

The usage examples are based on the assumption that your built executable is named `mycli` and is in your current working directory. If you move the executable to a directory listed in your `PATH` environment variable, you can run it from anywhere without prefixing with `./`.

add Command


// Usage:
$ mycli task add [task description]

// Example:
$ mycli task add "Buy groceries"

This command will add a new task with the description "Buy groceries" to the todos table in the database. The task will be marked as "not completed."

list Command


// Usage:
$ mycli task list

// Example:
$ mycli task list

When this command is executed, it fetches all the tasks from the todos table in the database and lists them on the terminal. The list will include each task's ID, its completion status, and the task description.

delete Command


// Usage:
$ mycli task delete [task id]

// Example:
$ mycli task delete 1

This command deletes the task with ID 1 from the todos table in the database. If the ID is not provided or is invalid, an error message is displayed.

And that's it! We now have a basic cli that we can use for keeping track of task. We can expand more on this project if we need to add more functionality later. Check out my cli Maxxiene on Github to see what modifications I added.

Conclusion

Creating a CLI tool might seem daunting at first, but with the right tools, it becomes a straightforward task. Go's efficiency and Cobra's simplicity combine to make CLI development a breeze. We've only scratched the surface of what you can do with these tools. The possibilities are endless, so go ahead and build the next great CLI tool!

Back to Top