Building CLI Tools in Go

Go is exceptionally well-suited for building command-line interface (CLI) tools. Its fast compilation, single-binary output, and excellent standard library make it the go-to language for DevOps utilities, platform tools, and system administration commands. Many popular CLI tools like Docker, Kubernetes kubectl, Terraform, and Hugo are written in Go.

Why Go for CLI Tools

Go provides several advantages for CLI development:

graph LR A[Go Source] --> B[go build] B --> C[linux/amd64] B --> D[darwin/amd64] B --> E[windows/amd64]

Basic CLI Application

Simple CLI with flag Package

package main import ( "flag" "fmt" ) func main() { // Define flags name := flag.String("name", "World", "Name to greet") verbose := flag.Bool("verbose", false, "Enable verbose output") count := flag.Int("count", 1, "Number of times to greet") // Parse command line flag.Parse() if *verbose { fmt.Println("Greeting started...") } for i := 0; i < *count; i++ { fmt.Printf("Hello, %s!\n", *name) } } // Usage: // go run main.go -name Alice -verbose -count 3

Reading from Standard Input

package main import ( "bufio" "fmt" "os" ) func main() { scanner := bufio.NewScanner(os.Stdin) fmt.Print("Enter your name: ") scanner.Scan() name := scanner.Text() fmt.Printf("Hello, %s!\n", name) }

Using Cobra for Advanced CLI

Cobra is the most popular CLI framework in Go, used by Kubernetes, GitHub CLI, and many others.

Installing Cobra

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

Basic Cobra Application

package main import ( "fmt" "github.com/spf13/cobra" "os" ) var rootCmd = &cobra.Command{ Use: "myapp", Short: "MyApp is a sample CLI application", Long: `A longer description of the application.`, Run: func(cmd *cobra.Command, args []string) { fmt.Println("Hello from MyApp!") }, } func main() { if err := rootCmd.Execute(); err != nil { fmt.Println(err) os.Exit(1) } }

Adding Subcommands

package main import ( "fmt" "github.com/spf13/cobra" ) var ( name string age int ) var rootCmd = &cobra.Command{ Use: "myapp", Short: "MyApp CLI", } var versionCmd = &cobra.Command{ Use: "version", Short: "Print version information", Run: func(cmd *cobra.Command, args []string) { fmt.Println("MyApp v1.0.0") }, } var userCmd = &cobra.Command{ Use: "user", Short: "Manage users", } var createUserCmd = &cobra.Command{ Use: "create", Short: "Create a new user", Run: func(cmd *cobra.Command, args []string) { fmt.Printf("Creating user: %s (age: %d)\n", name, age) }, } func init() { // Add subcommands rootCmd.AddCommand(versionCmd) rootCmd.AddCommand(userCmd) userCmd.AddCommand(createUserCmd) // Add flags createUserCmd.Flags().StringVarP(&name, "name", "n", "", "User name (required)") createUserCmd.Flags().IntVarP(&age, "age", "a", 0, "User age") createUserCmd.MarkFlagRequired("name") } func main() { rootCmd.Execute() } // Usage: // myapp version // myapp user create -n Alice -a 30
graph TD A[myapp] --> B[version] A --> C[user] C --> D[create] C --> E[delete] C --> F[list]

Using Viper for Configuration

Viper handles configuration from files, environment variables, and command-line flags.

package main import ( "fmt" "github.com/spf13/cobra" "github.com/spf13/viper" ) var rootCmd = &cobra.Command{ Use: "myapp", Short: "MyApp with configuration", Run: func(cmd *cobra.Command, args []string) { fmt.Println("Host:", viper.GetString("host")) fmt.Println("Port:", viper.GetInt("port")) fmt.Println("Debug:", viper.GetBool("debug")) }, } func init() { // Set defaults viper.SetDefault("host", "localhost") viper.SetDefault("port", 8080) viper.SetDefault("debug", false) // Bind flags rootCmd.Flags().StringP("host", "h", "localhost", "Server host") rootCmd.Flags().IntP("port", "p", 8080, "Server port") rootCmd.Flags().BoolP("debug", "d", false, "Enable debug mode") viper.BindPFlag("host", rootCmd.Flags().Lookup("host")) viper.BindPFlag("port", rootCmd.Flags().Lookup("port")) viper.BindPFlag("debug", rootCmd.Flags().Lookup("debug")) // Read config file viper.SetConfigName("config") viper.SetConfigType("yaml") viper.AddConfigPath(".") viper.AddConfigPath("$HOME/.myapp") if err := viper.ReadInConfig(); err == nil { fmt.Println("Using config file:", viper.ConfigFileUsed()) } // Read from environment viper.AutomaticEnv() viper.SetEnvPrefix("MYAPP") } func main() { rootCmd.Execute() } // Config file: config.yaml // host: api.example.com // port: 9000 // debug: true // Environment: MYAPP_HOST=prod.example.com // Flags: --host localhost --port 3000

File Operations

package main import ( "fmt" "io/ioutil" "os" "path/filepath" ) // Read file func readFile(filename string) (string, error) { data, err := ioutil.ReadFile(filename) if err != nil { return "", err } return string(data), nil } // Write file func writeFile(filename, content string) error { return ioutil.WriteFile(filename, []byte(content), 0644) } // Check if file exists func fileExists(filename string) bool { _, err := os.Stat(filename) return !os.IsNotExist(err) } // List files in directory func listFiles(dir string) ([]string, error) { files := []string{} err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { if err != nil { return err } if !info.IsDir() { files = append(files, path) } return nil }) return files, err } // Create directory func createDir(path string) error { return os.MkdirAll(path, 0755) } func main() { // Write and read err := writeFile("test.txt", "Hello, World!") if err != nil { fmt.Println("Error writing:", err) return } content, err := readFile("test.txt") if err != nil { fmt.Println("Error reading:", err) return } fmt.Println("Content:", content) }

Progress Bars and Spinners

package main import ( "time" "github.com/schollz/progressbar/v3" ) func main() { bar := progressbar.Default(100) for i := 0; i < 100; i++ { bar.Add(1) time.Sleep(40 * time.Millisecond) } } // Spinner example import ( "time" "github.com/briandowns/spinner" ) func longRunningTask() { s := spinner.New(spinner.CharSets[9], 100*time.Millisecond) s.Start() time.Sleep(4 * time.Second) // Simulate work s.Stop() }

Colorized Output

package main import ( "github.com/fatih/color" ) func main() { // Basic colors color.Red("This is red") color.Green("This is green") color.Blue("This is blue") // Combined attributes color.New(color.FgRed, color.Bold).Println("Bold red") // Custom color success := color.New(color.FgGreen, color.Bold) success.Println("Success!") // Printf-style color.Yellow("Warning: %s", "something happened") }

Table Output

package main import ( "os" "github.com/olekukonko/tablewriter" ) func main() { data := [][]string{ {"Alice", "30", "Engineering"}, {"Bob", "25", "Marketing"}, {"Carol", "35", "Sales"}, } table := tablewriter.NewWriter(os.Stdout) table.SetHeader([]string{"Name", "Age", "Department"}) for _, row := range data { table.Append(row) } table.Render() } // Output: // +-------+-----+-------------+ // | NAME | AGE | DEPARTMENT | // +-------+-----+-------------+ // | Alice | 30 | Engineering | // | Bob | 25 | Marketing | // | Carol | 35 | Sales | // +-------+-----+-------------+

HTTP Requests

package main import ( "encoding/json" "fmt" "io/ioutil" "net/http" ) type User struct { ID int `json:"id"` Name string `json:"name"` } func getUsers() ([]User, error) { resp, err := http.Get("https://api.example.com/users") if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("API returned status %d", resp.StatusCode) } body, err := ioutil.ReadAll(resp.Body) if err != nil { return nil, err } var users []User err = json.Unmarshal(body, &users) return users, err } func main() { users, err := getUsers() if err != nil { fmt.Println("Error:", err) return } for _, user := range users { fmt.Printf("%d: %s\n", user.ID, user.Name) } }

Cross-Platform Build

# Build for current platform go build -o myapp # Build for Linux GOOS=linux GOARCH=amd64 go build -o myapp-linux # Build for macOS GOOS=darwin GOARCH=amd64 go build -o myapp-mac # Build for Windows GOOS=windows GOARCH=amd64 go build -o myapp.exe # Build for multiple platforms for GOOS in linux darwin windows; do for GOARCH in amd64 arm64; do output="myapp-$GOOS-$GOARCH" if [ "$GOOS" = "windows" ]; then output+=".exe" fi echo "Building $output" GOOS=$GOOS GOARCH=$GOARCH go build -o bin/$output done done
graph LR A[Go Source] --> B[Cross Compiler] B --> C[Linux Binary] B --> D[macOS Binary] B --> E[Windows Binary] B --> F[ARM Binary]

Practical Example: File Search CLI

package main import ( "fmt" "os" "path/filepath" "strings" "github.com/spf13/cobra" ) var ( searchPath string pattern string caseInsensitive bool ) var rootCmd = &cobra.Command{ Use: "filesearch", Short: "Search for files by name pattern", Run: searchFiles, } func init() { rootCmd.Flags().StringVarP(&searchPath, "path", "p", ".", "Path to search") rootCmd.Flags().StringVarP(&pattern, "pattern", "t", "", "Search pattern (required)") rootCmd.Flags().BoolVarP(&caseInsensitive, "ignore-case", "i", false, "Case insensitive search") rootCmd.MarkFlagRequired("pattern") } func searchFiles(cmd *cobra.Command, args []string) { matchCount := 0 searchPattern := pattern if caseInsensitive { searchPattern = strings.ToLower(searchPattern) } err := filepath.Walk(searchPath, func(path string, info os.FileInfo, err error) error { if err != nil { return err } if info.IsDir() { return nil } filename := filepath.Base(path) if caseInsensitive { filename = strings.ToLower(filename) } if strings.Contains(filename, searchPattern) { fmt.Println(path) matchCount++ } return nil }) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } fmt.Printf("\nFound %d files\n", matchCount) } func main() { if err := rootCmd.Execute(); err != nil { os.Exit(1) } } // Usage: // filesearch -t ".go" -p /home/user/projects // filesearch -t "test" -i

Testing CLI Applications

package main import ( "bytes" "testing" "github.com/spf13/cobra" ) func executeCommand(root *cobra.Command, args ...string) (output string, err error) { buf := new(bytes.Buffer) root.SetOut(buf) root.SetErr(buf) root.SetArgs(args) err = root.Execute() return buf.String(), err } func TestVersionCommand(t *testing.Command) { output, err := executeCommand(rootCmd, "version") if err != nil { t.Errorf("Unexpected error: %v", err) } expected := "MyApp v1.0.0" if !strings.Contains(output, expected) { t.Errorf("Expected output to contain %q, got %q", expected, output) } }

Key Takeaways