James James James is a student software developer at Oppia Foundation.

Using Cobra to build a CLI accounting app

8 min read 2444

Using Cobra To Build A CLI Accounting Application

When developers aren’t in their IDE text editors, they’re usually in the terminal.
As a developer, chances are high that you have used a command line interface (CLI) for your projects.

Most developer tools run on the command line for one main reason: easy configuration. CLI applications allow a degree of freedom that is not easily found in graphical user interface (GUI) applications.

Cobra is a Go library for building CLI applications. It is pretty popular and used in a lot of popular developer tools like the Github CLI, Hugo, and more.

In this tutorial, we’ll learn about Cobra by building a simple accounting CLI application that bills users, stores the information in a JSON file, records receipts, and tracks a user’s total balance.

Installing Cobra

There are two ways to create a Cobra application:

  1. Installing the Cobra Generator
  2. Manually adding Cobra to a Go application

For this tutorial, we’ll install the Cobra Generator. This provides an easy way to generate commands that give life to the application.

To begin, run the following command to install the Cobra Generator:

go get github.com/spf13/cobra/cobra

This installs Cobra in the GOPATH directory to then generate Cobra applications.

Understanding Cobra CLI commands and flags

Before we can start building our app, we must understand the main components of a CLI
application.

When using Git to clone a project, we usually run the following:

git clone <url.to.project>

This includes:

  • git, the application name
  • clone, the command
  • url.to.project, the argument passed to the command and the project we want git to clone

A CLI application usually comprises the application’s name, the command, flags, and arguments.

Consider this example:

npm install --save-dev nodemon

Here, npm is the application that’s running and install is the command. --save-dev is a flag that passes to the install command, while nodemon is the argument passed to the command.

Cobra allows us to create commands and add flags to them really easily. For our application, we’ll create two commands: credit and debit. And, using various flags, we can specify items like the user making the transaction, the amount transacted, and the narration for the transaction.

Creating the Cobra app

To create a new Cobra application, run the following command:

cobra init --pkg-name github.com/<username>/accountant accountant

This command creates a new folder, accountant, and creates a main.go file, a LICENSE file, and a cmd folder with a root.go file.

Note that this command does not create a go.mod file, so we must initialize the go module by ourselves:

go mod init github.com/<username>/accountant
go mod tidy

We can now run this as we would any normal Go application:

go run .

However, we could also build the app via the following:

go build .

And the run the application via the following:

./accountant

The Cobra app entry point

The entry point to our Cobra app is main.go and it is important to keep the main package lean so we can keep different aspects of the application separate. Looking at the Cobra-generated main.go file, we find that the main function has just one function: executing the root command:

cmd.Execute()

The root command, cmd/root.go, contains the following:

  • The rootCmd struct, which is a type of cobraCommand
  • The Execute function, which is called in main.go
  • The init function, which initializes the config and sets up the root flags
  • The initConfig function, which initializes any set configurations

Currently, running the application contains a bunch of Cobra-generated text. Let’s change that by modifying cmd\root.go to the following so we can explain what our app is for:

package cmd
import (
  "github.com/spf13/cobra"
)
// rootCmd represents the base command when called without any subcommands
var rootCmd = &cobra.Command{
  Use:   "accountant",
  Short: "An application that helps manage accounts of users",
  Long: `
This is a CLI that enables users to manage their accounts.
You would be able to add credit transactions and debit transactions to various users.
  `,
  // Uncomment the following line if your bare application
  // has an action associated with it:
  // Run: func(cmd *cobra.Command, args []string) { },
}
// Execute adds all child commands to the root command and sets flags appropriately.
// This is called by main.main(). It only needs to happen once to the rootCmd.
func Execute() {
  cobra.CheckErr(rootCmd.Execute())
}

Running the application should now give the following response:

This is a CLI that enables users to manage their accounts.
You would be able to add credit transactions and debit transactions to various users.

Usage:
  accountant [command]

Here, we removed the init and initConfig functions that Cobra generated. That is because we don’t need any environment variables for this application, and the root command is not doing much. Instead, all the features for the application are carried out by specific commands.



Creating commands in Cobra

Our application should be able to handle two main features: debiting and crediting users. Thus, we must create two commands: debit and credit.

Run the following to generate those commands:

cobra add credit
cobra add debit

This creates two new files: debit.go and credit.go in the /cmd directory.

Upon inspecting the newly created files, add the following into the init function:

rootCmd.AddCommand(debitCmd)

This line of code adds the newly-created command to the root command; now, the application is aware of the new command.

To run the debitCmd command, we must build the application via go build . and run the application like so:

./accountant debit

Adding a JSON storage layer

For this application, we’ll use a very simple storage layer. In this case, we’ll store our data in a JSON file and access them via a go module.

In the root directory, create a database folder, then create a db.go file and a db.json file.

Add the following to db.go to interact with the database:

package database
import (
  "encoding/json"
  "os"
  "strings"
)
type User struct {
  Username     string        `json:"username"`
  Balance      int64         `json:"balance"`
  Transactions []Transaction `json:"transactions"`
}
type Transaction struct {
  Amount    int64  `json:"amount"`
  Type      string `json:"string"`
  Narration string `json:"narration"`
}
func getUsers() ([]User, error) {
  data, err := os.ReadFile("database/db.json")
  var users []User
  if err == nil {
    json.Unmarshal(data, &users)
  }
  return users, err
}
func updateDB(data []User) {
  bytes, err := json.Marshal(data)
  if err == nil {
    os.WriteFile("database/db.json", bytes, 0644)
  } else {
    panic(err)
  }
}
func FindUser(username string) (*User, error) {
  users, err := getUsers()
  if err == nil {
    for index := 0; index < len(users); index++ {
      user := users[index]
      if strings.EqualFold(user.Username, username) {
        return &user, nil
      }
    }
  }
  return nil, err
}
func FindOrCreateUser(username string) (*User, error) {
  user, err := FindUser(username)
  if user == nil {
    var newUser User
    newUser.Username = strings.ToLower(username)
    newUser.Balance = 0
    newUser.Transactions = []Transaction{}
    users, err := getUsers()
    if err == nil {
      users = append(users, newUser)
      updateDB(users)
    }
    return &newUser, err
  }
  return user, err
}
func UpdateUser(user *User) {
  // Update the json with this modified user information
  users, err := getUsers()
  if err == nil {
    for index := 0; index < len(users); index++ {
      if strings.EqualFold(users[index].Username, user.Username) {
        // Update the user details
        users[index] = *user
      }
    }
    // update database
    updateDB(users)
  }
}

Here, we define two structures: User and Transaction. The User structure defines how to store and access a user’s information, such as a username, balance and transactions. The Transaction structure stores the transactions, including the amount, type and narration.

We also have two functions that write to the database. getUsers loads the database file and returns the stored user data while updateDB writes the updated data to the database.

These functions are private to this package and need public functions for the commands to interact with them.

FindUser finds a user in the database with a username and returns the user. If no user is found, it returns nil. FindOrCreateUser checks if there is a user with a username and returns it; if there is no user, it creates a new user with that user name and returns it.

UpdateUser receives user data and updates the corresponding entry in the database.

These three functions are exported to use in commands when crediting and debiting users.

Implementing credit transactions with Cobra

Modify the credit command with the following to create an adequate description for the command and add a usage section in the long description:

// cmd/credit.go
var creditCmd = &cobra.Command{
  Use:   "credit",
  Short: "Create a credit transaction",
  Long: `
This command creates a credit transaction for a particular user.
Usage: accountant credit <username> --amount=<amount> --narration=<narration>.`,
  Run: func(cmd *cobra.Command, args []string) {
  },
}

The long description then appears when a user tries to get help for this command.

Long Description Appears In Terminal When Users Need Help

Next, we must add the necessary flags for the credit command: amount and narration.

Add the following after the creditCmd definition:

var creditNarration string
var creditAmount int64

func init() {
  rootCmd.AddCommand(creditCmd)
  creditCmd.Flags().StringVarP(&creditNarration, "narration", "n", "", "Narration for this credit transaction")
  creditCmd.Flags().Int64VarP(&creditAmount, "amount", "a", 0, "Amount to be credited")
  creditCmd.MarkFlagRequired("narration")
  creditCmd.MarkFlagRequired("amount")
}

In the init method, we attach the creditCmd command to the root command via rootCmd.AddCommand.

Next, we must create a string flag, narration, using the StringVarP method. This method receives five parameters:

  • A pointer to the variable where the value of the flag is stored
  • The name of the flag
  • A short name for the flag
  • A default value for the flag
  • A help message is provided when the user asks for help via the --help flag

Also, we must create a new flag, amount, via the Int64VarP method. This method is similar to StringVarP but creates a 64bit integer flag.

After that, we must set both flags as required. By doing this, whenever the command is called without those flags, Cobra outputs an error stating that the flags are required.

Completing the credit command, we use the database functions to create transactions and add them to the users.

To do this, modify the run function to look like the following:

var creditCmd = &cobra.Command{
  ...
  Run: func(cmd *cobra.Command, args []string) {
    if len(args) < 1 {
      log.Fatal("Username not specified")
    }
    username := args[0]
    user, err := database.FindOrCreateUser(username)
    if err != nil {
      log.Fatal(err)
    }
    user.Balance = user.Balance + creditAmount
    creditTransaction := database.Transaction{Amount: creditAmount, Type: "credit", Narration: creditNarration}
    user.Transactions = append(user.Transactions, creditTransaction)
    database.UpdateUser(user)
    fmt.Println("Transaction created successfully")
  },
}

The run function is the most important part of the command because it handles the main action of the command.

So, we want the command to have the following signature:

./accountant credit <username> --amount=<amount> --narration<narration>

The argument sent to the command here is the username, more specifically, the first item in the args array. This ensures that there is at least one argument passed to the command.

After getting the username, we can use the FindOrCreateUser method from the database package to get the corresponding user information with that username.

If that operation is successful, we increment the user’s balance and add a new transaction with the amount and narration. Then, we update the database with the new user data.

Putting everything together, the credit command should look like this:

package cmd
import (
  "fmt"
  "log"
  "github.com/jameesjohn/accountant/database"
  "github.com/spf13/cobra"
)
// creditCmd represents the credit command
var creditCmd = &cobra.Command{
  Use:   "credit",
  Short: "Create a credit transaction",
  Long: `
This command creates a credit transaction for a particular user.
Usage: accountant credit <username> --amount=<amount> --narration=<narration>.`,
  Run: func(cmd *cobra.Command, args []string) {
    if len(args) < 1 {
      log.Fatal("Username not specified")
    }
    username := args[0]
    user, err := database.FindOrCreateUser(username)
    if err != nil {
      log.Fatal(err)
    }
    user.Balance = user.Balance + creditAmount
    creditTransaction := database.Transaction{Amount: creditAmount, Type: "credit", Narration: creditNarration}
    user.Transactions = append(user.Transactions, creditTransaction)
    database.UpdateUser(user)
    fmt.Println("Transaction created successfully")
  },
}
var creditNarration string
var creditAmount int64
func init() {
  rootCmd.AddCommand(creditCmd)
  creditCmd.Flags().StringVarP(&creditNarration, "narration", "n", "", "Narration for this credit transaction")
  creditCmd.Flags().Int64VarP(&creditAmount, "amount", "a", 0, "Amount to be credited")
  creditCmd.MarkFlagRequired("narration")
  creditCmd.MarkFlagRequired("amount")
}

With this, we have successfully implemented the credit command.

Implementing debit transactions with Cobra

The debit command looks similar to the credit command. The only difference is the run function. Debit reduces a user’s balance while credit increases the user’s balance.

The debit command should look like the following:

./accountant debit <username> --amount=<amount> --narration=<narration>

The difference of the run function for debit comes when checking that the user’s balance is greater than the amount debited; we wouldn’t want to have negative balances in our database.

To do this, modify debit.go to look like the following:

package cmd
import (
  "fmt"
  "log"
  "github.com/jameesjohn/accountant/database"
  "github.com/spf13/cobra"
)
// debitCmd represents the debit command
var debitCmd = &cobra.Command{
  Use:   "debit",
  Short: "Create a debit transaction",
  Long: `
This command creates a debit transaction for a particular user.
Usage: accountant debit <username> --amount=<amount> --narration=<narration>.`,
  Run: func(cmd *cobra.Command, args []string) {
    if len(args) < 1 {
      log.Fatal("Username not specified")
    }
    username := args[0]
    user, err := database.FindOrCreateUser(username)
    if err != nil {
      log.Fatal(err)
    }
    if user.Balance > debitAmount {
      user.Balance = user.Balance - debitAmount
      debitTransaction := database.Transaction{Amount: debitAmount, Type: "debit", Narration: debitNarration}
      user.Transactions = append(user.Transactions, debitTransaction)
      database.UpdateUser(user)
      fmt.Println("Transaction created successfully")
    } else {
      fmt.Println("Insufficient funds!")
    }
  },
}

var debitNarration string
var debitAmount int64

func init() {
  rootCmd.AddCommand(debitCmd)
  debitCmd.Flags().StringVarP(&debitNarration, "narration", "n", "", "Narration for this debit transaction")
  debitCmd.Flags().Int64VarP(&debitAmount, "amount", "a", 0, "Amount to be debited")
  debitCmd.MarkFlagRequired("narration")
  debitCmd.MarkFlagRequired("amount")
}

If the user has enough balance to perform the transaction, we reduce their balance by the amount debited, create a new debit transaction, and add the transaction to the user. Finally, we update the database with the updated user.

If the user does not have enough balance, we output an error message stating that they have an insufficient balance.

We can now use the accountant to debit users:

./accountant debit henry --amount=40 --narration="Paid James"

The application can now be built by running go build.

Conclusion

We just learned how to use Cobra to build CLI apps! Considering the amount of work Cobra does for us, it’s not hard to see why popular open source applications and tools use it for their CLI applications.

This project can be found here.

Get setup with LogRocket's modern error tracking in minutes:

  1. Visit https://logrocket.com/signup/ to get an app ID.
  2. Install LogRocket via NPM or script tag. LogRocket.init() must be called client-side, not server-side.
  3. $ npm i --save logrocket 

    // Code:

    import LogRocket from 'logrocket';
    LogRocket.init('app/id');
    Add to your HTML:

    <script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script>
    <script>window.LogRocket && window.LogRocket.init('app/id');</script>
  4. (Optional) Install plugins for deeper integrations with your stack:
    • Redux middleware
    • ngrx middleware
    • Vuex plugin
Get started now
James James James is a student software developer at Oppia Foundation.

Leave a Reply