Build an interactive CLI app with Go, Cobra and promptui
- Text tutorial: https://divrhino.com/articles/build-interactive-cli-app-with-go-cobra-promptui
- Video tutorial: https://www.youtube.com/watch?v=so3VZwdWcBg
This website collects cookies to deliver better user experience
Studybuddy
. The app will ask us a series of questions and save our answers as a note. We will then be able to see all the notes we've saved. The following video demonstrates some interactive behaviour we will build:promptui
. We will create several commands to achieve our goal.studybuddy init
- creates a databasestudybuddy note
- displays information about commands related to notesstudybuddy note new
- opens a prompt to collect data from the userstudybuddy note list
- displays all the notes we've createdcommands
and subcommands
, so our cobra
app will be very simple.Sites
folder, or wherever you keep your projects, we will create a new project folder called studybuddy
. Then we will change into itmkdir -p studybuddy && cd studybuddy
cobra init --pkg-name github.com/divrhino/studybuddy
go modules
to manage our project dependencies, so we can set it up using the same package name we used in the above cobra init
commandgo mod init github.com/divrhino/studybuddy
go mod tidy
cmd/root.go
fileUse: "studybuddy",
Short: "Use studybuddy to learn and retain vocabulary",
Long: `Learn a new language with the studybuddy CLI app by your side`,
cobra
app. Let's build it and try it out. Run the following command to build the current projectgo build .
GOPATH
so we can't execute it simply by running studybuddy
in the terminal. Instead we can run it relative to the current project directory./studybuddy
SQLite
database. As the name suggests, SQLite
is a "lite" database, so it is less complicated than something like Postgres
. This makes it a great choice for a situation like ours where the primary focus is not the database
.data
folder and also create a file called data.go
within itmkdir data
touch data/data.go
go-sqlite3
package so we can use it in our projectgo get github.com/mattn/go-sqlite3
data/data.go
, we can start by importing the database/sql
and go-sqlite3
packages. These are the 2 packages we need to work with SQLite
in our apppackage data
import (
"database/sql"
_ "github.com/mattn/go-sqlite3"
)
db
to hold our database connection pool. This variable will be used in several functions.OpenDatabase()
function.OpenDatabase()
, we declare a variable for err
that we will use later in the sql.Open()
functionsql.Open()
to open up a connection pool, passing in our driver name (sqlite3
) and the path to our database. Notice that we are not re-declaring the db
or the err
variables. We want to assign the return value from sql.Open()
to the package-level db
variable we declared previouslydb.Ping()
to verify that the connection is alive
var db *sql.DB
func OpenDatabase() error {
var err error
db, err = sql.Open("sqlite3", "./sqlite-database.db")
if err != nil {
return err
}
return db.Ping()
}
OpenDatabase()
function is ready, will call it from func main()
. We do this so the entire app can have access to the connection pool. Our entire main.go
file should look something like this:package main
import (
"github.com/divrhino/studybuddy/cmd"
"github.com/divrhino/studybuddy/data"
)
func main() {
data.OpenDatabase()
cmd.Execute()
}
studybuddy
. Inside data/data.go
, and in the following code:CreateTable()
functionCreateTable()
function, we set up a variable called createTableSQL
to hold the SQL statement we need to create a table with the necessary columns. i.e. idNote
, word
, definition
, category
SQL
statement using the db.Prepare()
method on the package-level db
variable we created earlierdb.Prepare()
returns an error, we do some quick error handlingExec()
log.Println
func CreateTable() {
createTableSQL := `CREATE TABLE IF NOT EXISTS studybuddy (
"idNote" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"word" TEXT,
"definition" TEXT,
"category" TEXT
);`
statement, err := db.Prepare(createTableSQL)
if err != nil {
log.Fatal(err.Error())
}
statement.Exec()
log.Println("Studybuddy table created")
}
CreateTable()
function in our init
command, which we will create next.init
command. We want to run this command to create a new database table. The database connection pool will also open because once we start using the app, func main()
will call the OpenDatabase()
function.init
commandcobra add init
cmd/init.go
file to quickly update the short
and long
descriptions. Our short and long descriptions do not have to be the same. But since we don't want to spend too much time worrying about the content here, we will just have them be the same.Short: "Initialise a new studybuddy database and table",
Long: `Initialise a new studybuddy database and table.`,
cmd/init.go
file, we will:data
package we just createdRun
function of the initCmd
, we will call the function we just created above. i.e. CreateTable()
package cmd
import (
"github.com/divrhino/studybuddy/data"
"github.com/spf13/cobra"
)
var initCmd = &cobra.Command{
Use: "init",
Short: "Initialise a new studybuddy database and table",
Long: `Initialise a new studybuddy database and table.`,
Run: func(cmd *cobra.Command, args []string) {
data.CreateTable()
},
}
func init() {
rootCmd.AddCommand(initCmd)
}
go build .
sqlite-database.db
appears in our project root../studybuddy init
note
command. This command won't actually do a whole lot. It will just allow us to get more information about its subcommands
add
the note
commandcobra add note
Run
field. Removing Run
makes this command behave more like the help
commandnoteCmd
variable should look like this:var noteCmd = &cobra.Command{
Use: "note",
Short: "A note can be anything you'd like to study and review.",
Long: `A note can be anything you'd like to study and review.`,
}
note
command. Let's build the appgo build .
./studybuddy note
note
command.promptui
to help us accept user input data. Then we will persist it to our database.subcommand
under the previously-created note
command. When used, the subcommand
will take this form: studybuddy note new
. In the terminal, execute the following line:cobra add new -p 'noteCmd'
Use: "new",
Short: "Creates a new studybuddy note",
Long: `Creates a new studybuddy note`,
promptui
package to help us. We will have to install it first by running the following command in the terminal:go get github.com/manifoldco/promptui
promptContent
struct typetype promptContent struct {
errorMsg string
label string
}
promptui
package along with the other packages we need before we continuepackage cmd
import (
"errors"
"fmt"
"os"
"github.com/manifoldco/promptui"
"github.com/spf13/cobra"
)
promptui
package has a few key concepts:result
func promptGetInput(pc promptContent) string {
validate := func(input string) error {
if len(input) <= 0 {
return errors.New(pc.errorMsg)
}
return nil
}
templates := &promptui.PromptTemplates{
Prompt: "{{ . }} ",
Valid: "{{ . | green }} ",
Invalid: "{{ . | red }} ",
Success: "{{ . | bold }} ",
}
prompt := promptui.Prompt{
Label: pc.label,
Templates: templates,
Validate: validate,
}
result, err := prompt.Run()
if err != nil {
fmt.Printf("Prompt failed %v\n", err)
os.Exit(1)
}
fmt.Printf("Input: %s\n", result)
return result
}
promptContent
struct we created, above, we will set up a prompt to capture a word
and a definition
for the word.func createNewNote() {
wordPromptContent := promptContent{
"Please provide a word.",
"What word would you like to make a note of?",
}
word := promptGetInput(wordPromptContent)
definitionPromptContent := promptContent{
"Please provide a definition.",
fmt.Sprintf("What is the definition of %s?", word),
}
definition := promptGetInput(definitionPromptContent)
}
input
, we can also give the user some options to choose from. We achieve this by using SelectWithAdd
from promptui
. In the following code:items
, which will be of type []string
Other
index
to -1
because this index will never actually be present inside items
-1
, the prompt is kept open and more categories can be appended to the items
slicefunc promptGetSelect(pc promptContent) string {
items := []string{"animal", "food", "person", "object"}
index := -1
var result string
var err error
for index < 0 {
prompt := promptui.SelectWithAdd{
Label: pc.label,
Items: items,
AddLabel: "Other",
}
index, result, err = prompt.Run()
if index == -1 {
items = append(items, result)
}
}
if err != nil {
fmt.Printf("Prompt failed %v\n", err)
os.Exit(1)
}
fmt.Printf("Input: %s\n", result)
return result
}
promptGetSelect
function, we can return to our createNewNote()
function to capture the category
func createNewNote() {
wordPromptContent := promptContent{
"Please provide a word.",
"What word would you like to make a note of?",
}
word := promptGetInput(wordPromptContent)
definitionPromptContent := promptContent{
"Please provide a definition.",
fmt.Sprintf("What is the definition of the %s?", word),
}
definition := promptGetInput(definitionPromptContent)
categoryPromptContent := promptContent{
"Please provide a category.",
fmt.Sprintf("What category does %s belong to?", word),
}
category := promptGetSelect(categoryPromptContent)
}
data/data.go
, we need to create another method that interacts with our database. This time we want to INSERT
data:func InsertNote(word string, definition string, category string) {
insertNoteSQL := `INSERT INTO studybuddy(word, definition, category) VALUES (?, ?, ?)`
statement, err := db.Prepare(insertNoteSQL)
if err != nil {
log.Fatalln(err)
}
_, err = statement.Exec(word, definition, category)
if err != nil {
log.Fatalln(err)
}
log.Println("Inserted study note successfully")
}
new.go
, we'll have to import the data
package into cmd/new.go
import (
...
"github.com/divrhino/studybuddy/data"
...
)
createNewNote()
function, call data.InsertNote()
and pass in the word
, definition
and category
data we collected from the user:func createNewNote() {
wordPromptContent := promptContent{
"Please provide a word.",
"What word would you like to make a note of?",
}
word := promptGetInput(wordPromptContent)
definitionPromptContent := promptContent{
"Please provide a definition.",
fmt.Sprintf("What is the definition of the %s?", word),
}
definition := promptGetInput(definitionPromptContent)
categoryPromptContent := promptContent{
"Please provide a category.",
fmt.Sprintf("What category does %s belong to?", word),
}
category := promptGetSelect(categoryPromptContent)
data.InsertNote(word, definition, category)
}
createNewNote()
function inside Run
:var newCmd = &cobra.Command{
Use: "new",
Short: "Create a new note to study",
Long: `Create a new note to study.`,
Run: func(cmd *cobra.Command, args []string) {
createNewNote()
},
}
go build .
note new
command to trigger out little "interview":./studybuddy note new
data/data.go
files, we can create a function to do that. In the following code:DisplayAllNotes()
SQL
query to select everything from the studybuddy
table and order the results by word
func DisplayAllNotes() {
row, err := db.Query("SELECT * FROM studybuddy ORDER BY word")
if err != nil {
log.Fatal(err)
}
defer row.Close()
for row.Next() {
var idNote int
var word string
var definition string
var category string
row.Scan(&idNote, &word, &definition, &category)
log.Println("[", category, "] ", word, "—", definition)
}
}
list
subcommand. When used, the subcommand
will take this form: studybuddy note list
. In the terminal, execute the following line:cobra add list -p 'noteCmd'
cmd/list.go
and update it. In the following code:data
package.Short
and Long
descriptionsdata.DisplayAllNotes()
inside Run
package cmd
import (
"github.com/divrhino/studybuddy/data"
"github.com/spf13/cobra"
)
// listCmd represents the list command
var listCmd = &cobra.Command{
Use: "list",
Short: "See a list of all notes you've added",
Long: `See a list of all notes you've added.`,
Run: func(cmd *cobra.Command, args []string) {
data.DisplayAllNotes()
},
}
func init() {
noteCmd.AddCommand(listCmd)
}
go build .
./studybuddy note list
test
yourself. Display each word in a question and execute a prompt to collect the answer
to test questions.