Skip to main content

CLI Tool Development — cobra & flag

Go is an optimal language for CLI tool development. With single binary deployment, fast startup time, and cross-compilation support, it is widely used in DevOps tools.


Standard Library flag Package

For simple CLIs, the standard flag package is sufficient.

// main.go
package main

import (
"flag"
"fmt"
"os"
)

func main() {
// Define flags
host := flag.String("host", "localhost", "Server host")
port := flag.Int("port", 8080, "Server port")
verbose := flag.Bool("verbose", false, "Verbose output")
timeout := flag.Duration("timeout", 30*time.Second, "Connection timeout")

// Custom usage message
flag.Usage = func() {
fmt.Fprintf(os.Stderr, "Usage: %s [options]\n\nOptions:\n", os.Args[0])
flag.PrintDefaults()
}

flag.Parse()

// Remaining arguments after parsing (non-flag arguments)
args := flag.Args()

if *verbose {
fmt.Printf("Server: %s:%d, Timeout: %s\n", *host, *port, *timeout)
}

fmt.Printf("Remaining args: %v\n", args)
}
go run main.go -host=example.com -port=9090 -verbose file1.txt file2.txt
# Server: example.com:9090, Timeout: 30s
# Remaining args: [file1.txt file2.txt]

cobra — Production CLI Framework

go get github.com/spf13/cobra@latest
go install github.com/spf13/cobra-cli@latest # Code generator

Project Structure

mycli/
├── cmd/
│ ├── root.go ← Root command
│ ├── serve.go ← serve subcommand
│ ├── user.go ← user subcommand group
│ └── user_list.go ← user list subcommand
├── internal/
│ └── config/
│ └── config.go
└── main.go

Root Command (root.go)

package cmd

import (
"fmt"
"os"

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

var (
cfgFile string
verbose bool
)

var rootCmd = &cobra.Command{
Use: "mycli",
Short: "My CLI — A great tool",
Long: `mycli is a feature-complete CLI tool that
automates server management and deployments.`,
// Called before all subcommands
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
return initConfig()
},
}

func Execute() {
if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}

func init() {
// Persistent flags: applied to root + all subcommands
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "Config file path (default: $HOME/.mycli.yaml)")
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "Verbose output")

// Local flags: applied to this command only
rootCmd.Flags().BoolP("version", "V", false, "Print version")
}

func initConfig() error {
if cfgFile != "" {
viper.SetConfigFile(cfgFile)
} else {
home, _ := os.UserHomeDir()
viper.AddConfigPath(home)
viper.SetConfigName(".mycli")
}
viper.AutomaticEnv() // Automatic environment variable binding
return viper.ReadInConfig()
}

Subcommand (serve.go)

package cmd

import (
"fmt"
"net/http"

"github.com/spf13/cobra"
)

var (
servePort int
serveTLS bool
)

var serveCmd = &cobra.Command{
Use: "serve",
Short: "Start HTTP server",
Long: `Start an HTTP server on the specified port.`,
Example: ` mycli serve
mycli serve --port 9090
mycli serve --port 443 --tls`,
RunE: func(cmd *cobra.Command, args []string) error {
addr := fmt.Sprintf(":%d", servePort)
fmt.Printf("Starting server: %s (TLS: %v)\n", addr, serveTLS)
return http.ListenAndServe(addr, nil)
},
}

func init() {
rootCmd.AddCommand(serveCmd)

serveCmd.Flags().IntVarP(&servePort, "port", "p", 8080, "Port number")
serveCmd.Flags().BoolVar(&serveTLS, "tls", false, "Enable TLS")

// Required flags
// serveCmd.MarkFlagRequired("port")
}

Nested Subcommand (user.go)

package cmd

import "github.com/spf13/cobra"

// user command group
var userCmd = &cobra.Command{
Use: "user",
Short: "User management",
Long: "Commands for creating, viewing, and deleting users",
}

var userListCmd = &cobra.Command{
Use: "list",
Short: "List users",
RunE: func(cmd *cobra.Command, args []string) error {
format, _ := cmd.Flags().GetString("format")
limit, _ := cmd.Flags().GetInt("limit")
return listUsers(format, limit)
},
}

var userCreateCmd = &cobra.Command{
Use: "create <name> <email>",
Short: "Create a user",
Args: cobra.ExactArgs(2), // Exactly 2 arguments required
RunE: func(cmd *cobra.Command, args []string) error {
name, email := args[0], args[1]
return createUser(name, email)
},
}

func init() {
rootCmd.AddCommand(userCmd)
userCmd.AddCommand(userListCmd)
userCmd.AddCommand(userCreateCmd)

userListCmd.Flags().String("format", "table", "Output format (table|json|yaml)")
userListCmd.Flags().Int("limit", 20, "Maximum output")
}

main.go

package main

import "mycli/cmd"

func main() {
cmd.Execute()
}

Output Formatter Implementation

package cmd

import (
"encoding/json"
"fmt"
"os"
"text/tabwriter"
)

type User struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}

func printUsers(users []User, format string) error {
switch format {
case "json":
return json.NewEncoder(os.Stdout).Encode(users)

case "table":
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "ID\tNAME\tEMAIL")
fmt.Fprintln(w, "--\t----\t-----")
for _, u := range users {
fmt.Fprintf(w, "%d\t%s\t%s\n", u.ID, u.Name, u.Email)
}
return w.Flush()

default:
return fmt.Errorf("unknown format: %s", format)
}
}

Progress Bars & Color Output

go get github.com/fatih/color
go get github.com/schollz/progressbar/v3
import (
"github.com/fatih/color"
"github.com/schollz/progressbar/v3"
)

func deployWithProgress(items []string) {
// Color output
green := color.New(color.FgGreen, color.Bold)
red := color.New(color.FgRed)
yellow := color.New(color.FgYellow)

// Progress bar
bar := progressbar.NewOptions(len(items),
progressbar.OptionEnableColorCodes(true),
progressbar.OptionSetDescription("[cyan]Deploying...[reset]"),
progressbar.OptionShowCount(),
)

for _, item := range items {
if err := deploy(item); err != nil {
red.Printf("✗ %s: %v\n", item, err)
} else {
green.Printf("✓ %s\n", item)
}
bar.Add(1)
}

yellow.Println("\nDeployment complete!")
}

Cross Compilation

# Build Linux/Windows binaries from macOS
GOOS=linux GOARCH=amd64 go build -o dist/mycli-linux-amd64 .
GOOS=windows GOARCH=amd64 go build -o dist/mycli-windows-amd64.exe .
GOOS=darwin GOARCH=arm64 go build -o dist/mycli-darwin-arm64 .

# Reduce binary size and inject version info with ldflags
VERSION=$(git describe --tags --always)
go build -ldflags="-s -w -X main.version=${VERSION}" -o mycli .
// main.go
var version = "dev" // Injected via -ldflags

var versionCmd = &cobra.Command{
Use: "version",
Short: "Print version",
Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("mycli %s\n", version)
},
}

Configuration File Integration — viper

import "github.com/spf13/viper"

// Configuration hierarchy (higher priority wins)
// 1. Command-line flags
// 2. Environment variables (MYCLI_PORT)
// 3. Configuration file (.mycli.yaml)
// 4. Default values

viper.SetDefault("server.port", 8080)
viper.SetEnvPrefix("MYCLI") // MYCLI_SERVER_PORT
viper.AutomaticEnv()
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))

// Bind flags with viper
rootCmd.PersistentFlags().Int("port", 8080, "Port")
viper.BindPFlag("server.port", rootCmd.PersistentFlags().Lookup("port"))

// Read configuration value
port := viper.GetInt("server.port")

Key Takeaways

ToolPurpose
flagSimple flags, no external dependencies
cobraSubcommands, auto help, auto completion
viperUnified config file + environment variables + flags
tabwriterAligned table output
fatih/colorColored terminal output
  • cobra + viper are built by the same team, making integration seamless
  • Use RunE to return errors, preferred over Run
  • Use PersistentPreRunE for common auth/config initialization across all commands