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 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.