March 26, 20269 min read

Build a CLI Tool with Go — From Zero to Published

A step-by-step tutorial for building a professional CLI tool in Go using Cobra. Covers project setup, commands, flags, configuration, interactive prompts, testing, and publishing to Homebrew.

go cli cobra tutorial golang
Ad 336x280

Go produces single-binary executables with no runtime dependencies. That makes it the default choice for CLI tools. Your users download one file, run it, and it works. No installing Node, no Python version conflicts, no JVM startup time.

Every major CLI tool built in the last few years uses Go: Docker, Kubernetes (kubectl), Terraform, GitHub CLI (gh), Cloudflare's wrangler (v1), Hugo, and hundreds more.

We're going to build a real CLI tool from scratch -- a project scaffolder that generates project boilerplate from templates. By the end, you'll have something you can publish to Homebrew and share with other developers.

Project Setup

mkdir scaffold && cd scaffold
go mod init github.com/yourusername/scaffold

Install Cobra, the standard library for Go CLIs:

go get github.com/spf13/cobra@latest

Cobra gives you commands, subcommands, flags, argument validation, shell completions, and help text generation. It's what kubectl, Hugo, and gh use.

Project Structure

scaffold/
├── cmd/
│   ├── root.go        # Root command
│   ├── init.go        # scaffold init
│   ├── list.go        # scaffold list
│   └── version.go     # scaffold version
├── internal/
│   ├── templates/     # Template logic
│   └── config/        # Config handling
├── main.go
├── go.mod
└── go.sum

The Root Command

// cmd/root.go
package cmd

import (
"fmt"
"os"

"github.com/spf13/cobra"
)

var (
verbose bool
cfgFile string
)

var rootCmd = &cobra.Command{
Use: "scaffold",
Short: "Generate project boilerplate from templates",
Long: Scaffold is a project generator that creates boilerplate
from predefined templates. Supports Go, Node.js, Python, and custom templates.
,
}

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

func init() {
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose output")
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default: ~/.scaffold.yaml)")
}

// main.go
package main

import "github.com/yourusername/scaffold/cmd"

func main() {
cmd.Execute()
}

That's the skeleton. Running go run main.go already shows auto-generated help text.

Adding Commands

The init Command

// cmd/init.go
package cmd

import (
"fmt"
"os"
"path/filepath"

"github.com/spf13/cobra"
)

var (
template string
outputDir string
projectName string
)

var initCmd = &cobra.Command{
Use: "init [project-name]",
Short: "Initialize a new project from a template",
Long: Create a new project directory with boilerplate files from the specified template.,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
projectName = args[0]

if outputDir == "" {
outputDir = projectName
}

absPath, err := filepath.Abs(outputDir)
if err != nil {
return fmt.Errorf("invalid output directory: %w", err)
}

if _, err := os.Stat(absPath); err == nil {
return fmt.Errorf("directory already exists: %s", absPath)
}

if verbose {
fmt.Printf("Creating project %q from template %q\n", projectName, template)
fmt.Printf("Output directory: %s\n", absPath)
}

if err := generateProject(projectName, template, absPath); err != nil {
return fmt.Errorf("failed to generate project: %w", err)
}

fmt.Printf("✓ Created project %q in %s\n", projectName, absPath)
fmt.Printf("\n cd %s\n", outputDir)
fmt.Printf(" # Start coding!\n")

return nil
},
}

func init() {
initCmd.Flags().StringVarP(&template, "template", "t", "default", "template to use")
initCmd.Flags().StringVarP(&outputDir, "output", "o", "", "output directory (default: project name)")
rootCmd.AddCommand(initCmd)
}

Usage: scaffold init my-api --template go-rest

The list Command

// cmd/list.go
package cmd

import (
"fmt"
"os"
"text/tabwriter"

"github.com/spf13/cobra"
)

type Template struct {
Name string
Language string
Description string
}

var templates = []Template{
{"go-rest", "Go", "REST API with Chi router and PostgreSQL"},
{"go-cli", "Go", "CLI tool with Cobra"},
{"node-api", "TypeScript", "Express API with Prisma and JWT auth"},
{"next-app", "TypeScript", "Next.js app with Tailwind CSS"},
{"python-api", "Python", "FastAPI with SQLAlchemy"},
{"python-cli", "Python", "Click-based CLI tool"},
}

var listCmd = &cobra.Command{
Use: "list",
Short: "List available templates",
Aliases: []string{"ls"},
Run: func(cmd *cobra.Command, args []string) {
w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
fmt.Fprintln(w, "NAME\tLANGUAGE\tDESCRIPTION")
fmt.Fprintln(w, "----\t--------\t-----------")
for _, t := range templates {
fmt.Fprintf(w, "%s\t%s\t%s\n", t.Name, t.Language, t.Description)
}
w.Flush()
},
}

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

Output:

NAME         LANGUAGE     DESCRIPTION
---- -------- -----------
go-rest Go REST API with Chi router and PostgreSQL
go-cli Go CLI tool with Cobra
node-api TypeScript Express API with Prisma and JWT auth

The version Command

// cmd/version.go
package cmd

import (
"fmt"

"github.com/spf13/cobra"
)

// Set via ldflags at build time
var (
Version = "dev"
Commit = "none"
BuildDate = "unknown"
)

var versionCmd = &cobra.Command{
Use: "version",
Short: "Print version information",
Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("scaffold %s\n", Version)
if verbose {
fmt.Printf(" commit: %s\n", Commit)
fmt.Printf(" built: %s\n", BuildDate)
}
},
}

func init() {
rootCmd.AddCommand(versionCmd)
}

Inject version at build time:

go build -ldflags "-X github.com/yourusername/scaffold/cmd.Version=1.0.0 \
  -X github.com/yourusername/scaffold/cmd.Commit=$(git rev-parse --short HEAD) \
  -X github.com/yourusername/scaffold/cmd.BuildDate=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
  -o scaffold .

Flags Deep Dive

Cobra supports two kinds of flags:

Persistent flags — available on the command and all its subcommands:
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose output")
// Available on: scaffold, scaffold init, scaffold list, etc.
Local flags — only available on the specific command:
initCmd.Flags().StringVarP(&template, "template", "t", "default", "template to use")
// Only available on: scaffold init
Required flags:
initCmd.Flags().StringVarP(&template, "template", "t", "", "template to use (required)")
initCmd.MarkFlagRequired("template")
Flag types:
cmd.Flags().String("name", "default", "a string flag")
cmd.Flags().Int("count", 10, "an integer flag")
cmd.Flags().Bool("dry-run", false, "a boolean flag")
cmd.Flags().StringSlice("tags", []string{}, "a slice flag: --tags a,b,c")
cmd.Flags().Duration("timeout", 30*time.Second, "a duration flag: --timeout 5m")

Configuration with Viper

Viper integrates with Cobra for configuration file support:

go get github.com/spf13/viper@latest
// cmd/root.go
import "github.com/spf13/viper"

func initConfig() {
if cfgFile != "" {
viper.SetConfigFile(cfgFile)
} else {
home, _ := os.UserHomeDir()
viper.AddConfigPath(home)
viper.SetConfigName(".scaffold")
viper.SetConfigType("yaml")
}

viper.SetEnvPrefix("SCAFFOLD")
viper.AutomaticEnv() // Read SCAFFOLD_* env vars

if err := viper.ReadInConfig(); err == nil {
if verbose {
fmt.Println("Using config:", viper.ConfigFileUsed())
}
}
}

func init() {
cobra.OnInitialize(initConfig)
}

Config file ~/.scaffold.yaml:

default_template: go-rest
author: Alice Chen
license: MIT
github_username: alicechen

Access config values:

template := viper.GetString("default_template")
author := viper.GetString("author")

Priority order: flags > environment variables > config file > defaults.

Interactive Prompts

For user-friendly flows, add interactive prompts with survey or huh:

go get github.com/charmbracelet/huh@latest
import "github.com/charmbracelet/huh"

func interactiveInit() (*ProjectConfig, error) {
var config ProjectConfig

form := huh.NewForm(
huh.NewGroup(
huh.NewInput().
Title("Project name").
Value(&config.Name).
Validate(func(s string) error {
if s == "" {
return fmt.Errorf("project name is required")
}
return nil
}),

huh.NewSelect[string]().
Title("Template").
Options(
huh.NewOption("Go REST API", "go-rest"),
huh.NewOption("Go CLI Tool", "go-cli"),
huh.NewOption("Node.js API", "node-api"),
huh.NewOption("Next.js App", "next-app"),
huh.NewOption("Python API", "python-api"),
).
Value(&config.Template),

huh.NewConfirm().
Title("Initialize git repository?").
Value(&config.InitGit),
),
)

err := form.Run()
return &config, err
}

The charm libraries (huh, bubbletea, lipgloss) are the gold standard for terminal UIs in Go.

Output and Colors

go get github.com/charmbracelet/lipgloss@latest
import "github.com/charmbracelet/lipgloss"

var (
successStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#22c55e")).Bold(true)
errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#ef4444")).Bold(true)
dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#6b7280"))
)

func printSuccess(msg string) {
fmt.Println(successStyle.Render("✓ " + msg))
}

func printError(msg string) {
fmt.Fprintln(os.Stderr, errorStyle.Render("✗ " + msg))
}

func printStep(msg string) {
fmt.Println(dimStyle.Render(" → " + msg))
}

Important: always write errors to stderr, not stdout. This lets users pipe stdout without error messages corrupting the output:
scaffold list | grep go    # Only gets template data, not error messages

Testing

// cmd/init_test.go
package cmd

import (
"bytes"
"os"
"path/filepath"
"testing"
)

func TestInitCommand(t *testing.T) {
// Create a temp directory for test output
tmpDir := t.TempDir()
outputPath := filepath.Join(tmpDir, "test-project")

// Capture output
buf := new(bytes.Buffer)
rootCmd.SetOut(buf)
rootCmd.SetErr(buf)
rootCmd.SetArgs([]string{"init", "test-project", "-t", "go-rest", "-o", outputPath})

err := rootCmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

// Verify directory was created
if _, err := os.Stat(outputPath); os.IsNotExist(err) {
t.Error("output directory was not created")
}

// Verify expected files exist
expectedFiles := []string{"main.go", "go.mod", "README.md"}
for _, f := range expectedFiles {
path := filepath.Join(outputPath, f)
if _, err := os.Stat(path); os.IsNotExist(err) {
t.Errorf("expected file not found: %s", f)
}
}
}

func TestInitCommand_DirectoryExists(t *testing.T) {
tmpDir := t.TempDir()

rootCmd.SetArgs([]string{"init", "test-project", "-o", tmpDir})
err := rootCmd.Execute()

if err == nil {
t.Error("expected error when directory exists, got nil")
}
}

func TestListCommand(t *testing.T) {
buf := new(bytes.Buffer)
rootCmd.SetOut(buf)
rootCmd.SetArgs([]string{"list"})

err := rootCmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

output := buf.String()
if !bytes.Contains([]byte(output), []byte("go-rest")) {
t.Error("expected go-rest template in list output")
}
}

Run tests:

go test ./cmd/ -v

Cross-Platform Builds

Go cross-compiles natively. Build for all platforms from a single machine:

# Build for all platforms
GOOS=linux GOARCH=amd64 go build -o dist/scaffold-linux-amd64 .
GOOS=linux GOARCH=arm64 go build -o dist/scaffold-linux-arm64 .
GOOS=darwin GOARCH=amd64 go build -o dist/scaffold-darwin-amd64 .
GOOS=darwin GOARCH=arm64 go build -o dist/scaffold-darwin-arm64 .
GOOS=windows GOARCH=amd64 go build -o dist/scaffold-windows-amd64.exe .

Or use GoReleaser to automate everything:

# .goreleaser.yaml
version: 2
builds:
  - env:
      - CGO_ENABLED=0
    goos:
      - linux
      - darwin
      - windows
    goarch:
      - amd64
      - arm64
    ldflags:
      - -s -w
      - -X github.com/yourusername/scaffold/cmd.Version={{.Version}}
      - -X github.com/yourusername/scaffold/cmd.Commit={{.ShortCommit}}
      - -X github.com/yourusername/scaffold/cmd.BuildDate={{.Date}}

brews:
- repository:
owner: yourusername
name: homebrew-tap
homepage: https://github.com/yourusername/scaffold
description: Project scaffolding tool

archives:
- format: tar.gz
name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}"
format_overrides:
- goos: windows
format: zip

goreleaser release --clean

GoReleaser creates GitHub releases, builds binaries, generates checksums, and pushes to your Homebrew tap. One command.

Publishing to Homebrew

Create a tap repository (homebrew-tap) and GoReleaser handles the formula. Users install with:

brew tap yourusername/tap
brew install scaffold

For NPM distribution (reaches Node.js developers):

{
  "name": "scaffold-cli",
  "version": "1.0.0",
  "bin": { "scaffold": "bin/scaffold" },
  "scripts": {
    "postinstall": "node scripts/install.js"
  }
}

The install script downloads the correct binary for the user's platform.

Shell Completions

Cobra generates completion scripts automatically:

var completionCmd = &cobra.Command{
	Use:   "completion [bash|zsh|fish|powershell]",
	Short: "Generate shell completion scripts",
	Args:  cobra.ExactArgs(1),
	RunE: func(cmd *cobra.Command, args []string) error {
		switch args[0] {
		case "bash":
			return rootCmd.GenBashCompletion(os.Stdout)
		case "zsh":
			return rootCmd.GenZshCompletion(os.Stdout)
		case "fish":
			return rootCmd.GenFishCompletion(os.Stdout, true)
		case "powershell":
			return rootCmd.GenPowerShellCompletionWithDesc(os.Stdout)
		default:
			return fmt.Errorf("unsupported shell: %s", args[0])
		}
	},
}

Users add completions:

scaffold completion bash > /etc/bash_completion.d/scaffold
scaffold completion zsh > "${fpath[1]}/_scaffold"

Tab completion makes any CLI tool feel professional. It's free with Cobra -- just expose the command.

Go gives you fast startup (under 10ms), a single binary, cross-platform builds, and the entire ecosystem of libraries for terminal UIs. If you're building developer tools, it's the pragmatic choice. You can prototype CLI ideas quickly on CodeUp and graduate them to full Go implementations when they prove useful.

Ad 728x90