How to Build a CLI Tool with Claude Code and Go
Go is the language behind Docker, Kubernetes, Terraform, Hugo, and gh. It is the number one choice for building CLI tools, and for good reason: fast compilation, single binary distribution with zero runtime dependencies, excellent flag and argument parsing, and a rich ecosystem of libraries purpose-built for command-line applications.
Claude Code makes building Go CLIs even faster. Instead of manually wiring up command trees, writing flag parsing boilerplate, and setting up project structure from scratch, you describe what you want and Claude generates it. And when you organize the workflow with Beam, you get a development environment where building, testing, and iterating on your CLI happens in a single, clean workspace.
This guide walks through the entire process: scaffolding a Cobra-based CLI project, building commands one by one, adding polish with colored output and config files, writing table-driven tests, cross-compiling for every platform, and distributing via Homebrew — all driven by Claude Code prompts and organized in Beam.
The finished CLI: taskr — a task manager built with Go, Cobra, and Claude Code.
Why Go Is the Best Language for CLI Tools
Before we start building, it is worth understanding why Go dominates the CLI tool landscape. The reasons are practical and compound on each other.
Single binary distribution. When you run go build, you get a single statically-linked binary with zero runtime dependencies. No Python virtual environments, no Node.js installations, no Java runtimes. Your users download one file, put it in their PATH, and it works. This is why Docker, Kubernetes, Terraform, Hugo, and GitHub's own gh CLI are all written in Go.
Cross-compilation is built in. Go can compile for any operating system and architecture from any machine. A single command — GOOS=linux GOARCH=amd64 go build — produces a Linux binary from your Mac. No cross-compilation toolchains, no Docker containers for building. You set two environment variables and the compiler does the rest.
Fast startup time. Go binaries start in milliseconds. Users expect CLI tools to feel instant. A Python CLI has a noticeable pause as the interpreter loads. A Go binary is running before the user's finger lifts from the Enter key.
Excellent standard library. Go ships with flag for argument parsing, os and io for file system and stream operations, encoding/json for data serialization, net/http for making API calls, and text/tabwriter for formatted output. You can build a useful CLI with nothing beyond the standard library.
Rich ecosystem for CLI development. Beyond the standard library, Go has a mature set of CLI-specific libraries:
- Cobra — The industry-standard command framework. Used by Kubernetes, Hugo, and GitHub CLI. Handles subcommands, flags, argument validation, help text generation, and shell completions.
- Viper — Configuration management that reads from flags, environment variables, config files (YAML, TOML, JSON), and remote key-value stores. Pairs seamlessly with Cobra.
- Lipgloss — Styled terminal output with colors, borders, padding, and alignment. Built by the Charm team.
- Bubbletea — Full terminal user interfaces with the Elm architecture. Spinners, progress bars, text inputs, tables, file pickers, and more.
- fatih/color — Simple, no-fuss colored output for when you do not need the full Lipgloss styling system.
- urfave/cli — A lighter alternative to Cobra for simpler CLIs that do not need deep subcommand trees.
Go gives you everything you need for CLI development, and Claude Code knows all of these libraries intimately.
Setting Up Your Beam Workspace
The best way to build a CLI tool with Claude Code is to organize your terminal environment so that building, running, and testing all happen side by side. Beam's workspaces make this straightforward.
Create a "CLI Dev" Workspace
Press ⌘N to create a new workspace in Beam. Name it after your CLI project — we will call ours "taskr". Set up three tabs:
- Tab 1: Claude Code — Your AI coding agent. This is where you prompt Claude to generate commands, add flags, wire up configuration, and write tests. Start it by typing
claude. - Tab 2:
go run/go build— Your build and test-run tab. After Claude generates code, switch here to rungo build -o taskr ./cmd/taskrand then./taskr listto see the result immediately. - Tab 3:
go test— A dedicated tab for running your test suite. Usego test -v ./...to watch all tests, or scope to a specific package while debugging.
Split Panes for Live Iteration
Press ⌘⌥⌃T to split Tab 1 into two panes. Put Claude Code on the left and a shell on the right where you run go build && ./taskr list. Now you can:
- Ask Claude Code to add a new command or fix a bug on the left
- Build and run the CLI on the right to see the result instantly
- Iterate without ever switching tabs or windows
Pro Tip: Save Your CLI Development Layout
Once your workspace is set up — tabs named, panes split, working directories set — press ⌘S to save it as a layout. Tomorrow, restore the entire workspace with a single action and pick up exactly where you left off.
Scaffolding a Cobra CLI with Claude Code
Cobra is the de facto standard for Go CLI tools. It powers kubectl, hugo, gh, and hundreds of other production CLIs. Instead of manually creating the project structure and wiring up the command tree, let Claude Code generate the entire scaffold.
Claude Code generates the entire project tree:
taskr/
cmd/
taskr/
main.go # Entrypoint, calls cmd.Execute()
internal/
cmd/
root.go # Root command, persistent flags, config init
add.go # Add a new task
list.go # List all tasks with table output
done.go # Mark a task as complete
delete.go # Remove a task
task/
store.go # JSON file read/write operations
task.go # Task struct and business logic
go.mod
go.sum
Let us look at what Claude Code generates for the key files.
The Root Command
Every Cobra CLI starts with a root command that defines the top-level behavior and persistent flags:
// internal/cmd/root.go
package cmd
import (
"fmt"
"os"
"github.com/spf13/cobra"
)
var rootCmd = &cobra.Command{
Use: "taskr",
Short: "A fast, local task manager for the command line",
Long: `taskr is a CLI task manager that stores tasks in a local JSON file.
Add tasks with priorities and due dates, list them in a formatted table,
mark them done, and delete them when you are finished.`,
}
func Execute() {
if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func init() {
rootCmd.PersistentFlags().StringP("file", "f", "", "path to task file (default ~/.taskr.json)")
}
The Task Model and Store
Claude Code generates a clean data model and a file-based storage layer:
// internal/task/task.go
package task
import "time"
type Priority string
const (
PriorityHigh Priority = "high"
PriorityMedium Priority = "medium"
PriorityLow Priority = "low"
)
type Status string
const (
StatusPending Status = "pending"
StatusDone Status = "done"
)
type Task struct {
ID int `json:"id"`
Title string `json:"title"`
Priority Priority `json:"priority"`
DueDate string `json:"due_date,omitempty"`
Status Status `json:"status"`
CreatedAt time.Time `json:"created_at"`
}
// internal/task/store.go
package task
import (
"encoding/json"
"os"
"path/filepath"
)
type Store struct {
Path string
Tasks []Task
}
func NewStore(path string) (*Store, error) {
if path == "" {
home, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("getting home directory: %w", err)
}
path = filepath.Join(home, ".taskr.json")
}
s := &Store{Path: path}
if err := s.load(); err != nil {
return nil, err
}
return s, nil
}
func (s *Store) load() error {
data, err := os.ReadFile(s.Path)
if os.IsNotExist(err) {
s.Tasks = []Task{}
return nil
}
if err != nil {
return fmt.Errorf("reading task file: %w", err)
}
return json.Unmarshal(data, &s.Tasks)
}
func (s *Store) save() error {
data, err := json.MarshalIndent(s.Tasks, "", " ")
if err != nil {
return fmt.Errorf("marshaling tasks: %w", err)
}
return os.WriteFile(s.Path, data, 0644)
}
Now install the dependencies. In your build tab:
cd taskr
go mod tidy
Cobra and its dependencies are fetched automatically. The project compiles on the first try.
Building Commands One by One
With the scaffold in place, we build out each command by prompting Claude Code. This is the core development loop: describe the behavior, review the generated code, build, and test.
The Add Command
// internal/cmd/add.go
package cmd
import (
"fmt"
"github.com/myorg/taskr/internal/task"
"github.com/spf13/cobra"
)
var addCmd = &cobra.Command{
Use: "add [title]",
Short: "Add a new task",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
store, err := getStore(cmd)
if err != nil {
return err
}
priority, _ := cmd.Flags().GetString("priority")
due, _ := cmd.Flags().GetString("due")
t := store.Add(args[0], task.Priority(priority), due)
fmt.Printf("✓ Task added: %q (id: %d)\n", t.Title, t.ID)
return nil
},
}
func init() {
addCmd.Flags().StringP("priority", "p", "medium", "task priority (high, medium, low)")
addCmd.Flags().StringP("due", "d", "", "due date (YYYY-MM-DD)")
rootCmd.AddCommand(addCmd)
}
Test it immediately in the build tab:
$ go build -o taskr ./cmd/taskr
$ ./taskr add "Set up CI pipeline" --priority high --due 2026-02-14
✓ Task added: "Set up CI pipeline" (id: 1)
The List Command
// internal/cmd/list.go
package cmd
import (
"fmt"
"os"
"text/tabwriter"
"github.com/myorg/taskr/internal/task"
"github.com/spf13/cobra"
)
var listCmd = &cobra.Command{
Use: "list",
Short: "List all tasks",
Aliases: []string{"ls"},
RunE: func(cmd *cobra.Command, args []string) error {
store, err := getStore(cmd)
if err != nil {
return err
}
showAll, _ := cmd.Flags().GetBool("all")
tasks := store.List(showAll)
if len(tasks) == 0 {
fmt.Println("No tasks found. Use 'taskr add' to create one.")
return nil
}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 4, ' ', 0)
fmt.Fprintln(w, "ID\tTASK\tPRIORITY\tDUE\tSTATUS")
for _, t := range tasks {
due := t.DueDate
if due == "" {
due = "-"
}
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\n",
t.ID, t.Title, t.Priority, due, t.Status)
}
w.Flush()
// Summary
done := store.CountByStatus(task.StatusDone)
pending := store.CountByStatus(task.StatusPending)
fmt.Printf("\n%d tasks: %d done, %d pending\n", done+pending, done, pending)
return nil
},
}
func init() {
listCmd.Flags().BoolP("all", "a", false, "show all tasks including completed")
rootCmd.AddCommand(listCmd)
}
The Done Command
// internal/cmd/done.go
package cmd
import (
"fmt"
"strconv"
"github.com/spf13/cobra"
)
var doneCmd = &cobra.Command{
Use: "done [id...]",
Short: "Mark tasks as completed",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
store, err := getStore(cmd)
if err != nil {
return err
}
for _, arg := range args {
id, err := strconv.Atoi(arg)
if err != nil {
return fmt.Errorf("invalid task ID %q: %w", arg, err)
}
t, err := store.MarkDone(id)
if err != nil {
return err
}
fmt.Printf("✓ Completed: %q (id: %d)\n", t.Title, t.ID)
}
return nil
},
}
func init() {
rootCmd.AddCommand(doneCmd)
}
The Delete Command
// internal/cmd/delete.go
package cmd
import (
"bufio"
"fmt"
"os"
"strconv"
"strings"
"github.com/spf13/cobra"
)
var deleteCmd = &cobra.Command{
Use: "delete [id]",
Short: "Delete a task",
Aliases: []string{"rm"},
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
store, err := getStore(cmd)
if err != nil {
return err
}
id, err := strconv.Atoi(args[0])
if err != nil {
return fmt.Errorf("invalid task ID %q: %w", args[0], err)
}
t, err := store.GetByID(id)
if err != nil {
return err
}
force, _ := cmd.Flags().GetBool("force")
if !force {
fmt.Printf("Delete task %d: %q? [y/N] ", t.ID, t.Title)
reader := bufio.NewReader(os.Stdin)
answer, _ := reader.ReadString('\n')
if strings.TrimSpace(strings.ToLower(answer)) != "y" {
fmt.Println("Cancelled.")
return nil
}
}
if err := store.Delete(id); err != nil {
return err
}
fmt.Printf("✓ Deleted: %q (id: %d)\n", t.Title, t.ID)
return nil
},
}
func init() {
deleteCmd.Flags().BoolP("force", "f", false, "skip confirmation prompt")
rootCmd.AddCommand(deleteCmd)
}
After each command, build and test in your side pane:
$ go build -o taskr ./cmd/taskr && ./taskr add "Write API docs" -p medium -d 2026-02-18
✓ Task added: "Write API docs" (id: 2)
$ ./taskr list
ID TASK PRIORITY DUE STATUS
1 Set up CI pipeline high 2026-02-14 pending
2 Write API docs medium 2026-02-18 pending
2 tasks: 0 done, 2 pending
$ ./taskr done 1
✓ Completed: "Set up CI pipeline" (id: 1)
$ ./taskr delete 2 --force
✓ Deleted: "Write API docs" (id: 2)
Each command works in isolation, has proper error handling, and follows the Cobra conventions that users of kubectl and gh already know.
Adding Polish: Colors, Config, and Completions
A functional CLI is good. A polished CLI is what people actually enjoy using. Claude Code can add professional-grade polish in minutes.
Colored Output with Lipgloss
import "github.com/charmbracelet/lipgloss"
var (
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#fafafa"))
highStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#ef4444")).Bold(true)
mediumStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#eab308"))
lowStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#3b82f6"))
doneStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#22c55e"))
pendingStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#eab308"))
)
func colorPriority(p task.Priority) string {
switch p {
case task.PriorityHigh:
return highStyle.Render(string(p))
case task.PriorityMedium:
return mediumStyle.Render(string(p))
case task.PriorityLow:
return lowStyle.Render(string(p))
default:
return string(p)
}
}
func colorStatus(s task.Status) string {
switch s {
case task.StatusDone:
return doneStyle.Render(string(s))
default:
return pendingStyle.Render(string(s))
}
}
Now taskr list produces the colorful output shown in the SVG diagram above. High-priority tasks stand out in red, completed tasks are green, and everything is easy to scan at a glance.
Config File Support with Viper
// internal/cmd/root.go (updated init)
import "github.com/spf13/viper"
func initConfig() {
viper.SetConfigName(".taskr")
viper.SetConfigType("yaml")
viper.AddConfigPath("$HOME")
viper.SetEnvPrefix("TASKR")
viper.AutomaticEnv()
// Defaults
viper.SetDefault("default_priority", "medium")
viper.SetDefault("color", true)
if err := viper.ReadInConfig(); err != nil {
// Config file is optional, not an error if missing
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
fmt.Fprintln(os.Stderr, "Warning: error reading config:", err)
}
}
}
func init() {
cobra.OnInitialize(initConfig)
rootCmd.PersistentFlags().StringP("file", "f", "", "path to task file")
viper.BindPFlag("file_path", rootCmd.PersistentFlags().Lookup("file"))
}
Users can now create a ~/.taskr.yaml to set their preferences:
# ~/.taskr.yaml
default_priority: high
file_path: ~/Documents/tasks.json
color: true
Environment variables also work: TASKR_DEFAULT_PRIORITY=low taskr add "Quick task". The precedence is flags > environment variables > config file > defaults. Viper handles this automatically.
Shell Completions
// internal/cmd/completion.go
package cmd
import (
"os"
"github.com/spf13/cobra"
)
var completionCmd = &cobra.Command{
Use: "completion [bash|zsh|fish|powershell]",
Short: "Generate shell completion scripts",
Long: `Generate completion scripts for your shell. For example:
# Bash
taskr completion bash > /usr/local/etc/bash_completion.d/taskr
# Zsh
taskr completion zsh > "${fpath[1]}/_taskr"
# Fish
taskr completion fish > ~/.config/fish/completions/taskr.fish`,
Args: cobra.ExactValidArgs(1),
ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
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)
}
return nil
},
}
func init() {
rootCmd.AddCommand(completionCmd)
}
// In add.go, register custom completion for --priority:
func init() {
addCmd.Flags().StringP("priority", "p", "medium", "task priority")
addCmd.RegisterFlagCompletionFunc("priority", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return []string{"high", "medium", "low"}, cobra.ShellCompDirectiveNoFileComp
})
rootCmd.AddCommand(addCmd)
}
Version Command with Build Info
// internal/cmd/version.go
package cmd
import (
"fmt"
"runtime"
"github.com/spf13/cobra"
)
var (
version = "dev"
commit = "none"
date = "unknown"
)
var versionCmd = &cobra.Command{
Use: "version",
Short: "Print version information",
Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("taskr %s\n", version)
fmt.Printf(" commit: %s\n", commit)
fmt.Printf(" built: %s\n", date)
fmt.Printf(" go: %s\n", runtime.Version())
fmt.Printf(" os/arch: %s/%s\n", runtime.GOOS, runtime.GOARCH)
},
}
func init() {
rootCmd.AddCommand(versionCmd)
}
Build with version info injected at compile time:
go build -ldflags "-X github.com/myorg/taskr/internal/cmd.version=1.0.0 \
-X github.com/myorg/taskr/internal/cmd.commit=$(git rev-parse --short HEAD) \
-X github.com/myorg/taskr/internal/cmd.date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
-o taskr ./cmd/taskr
$ ./taskr version
taskr 1.0.0
commit: a3f7c2d
built: 2026-02-11T14:30:00Z
go: go1.22.1
os/arch: darwin/arm64
Why Cobra + Viper Is the Standard
Cobra and Viper were created by Steve Francia, who also created Hugo. They are used together in virtually every major Go CLI: Kubernetes, Docker, Hugo, GitHub CLI, HashiCorp tools, and more. When Claude Code generates Cobra + Viper scaffolding, it is generating the same architecture that powers the most widely used CLIs in the world. Your tool inherits the same UX conventions users already know.
Testing Your CLI
Go's testing culture is rigorous, and CLI tools are no exception. Claude Code generates comprehensive table-driven tests that cover every command, flag combination, and edge case.
Table-Driven Tests for Commands
// internal/cmd/add_test.go
package cmd
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/myorg/taskr/internal/task"
)
func TestAddCommand(t *testing.T) {
tests := []struct {
name string
args []string
wantErr bool
wantTask *task.Task
}{
{
name: "valid task with all flags",
args: []string{"add", "Deploy to prod", "--priority", "high", "--due", "2026-03-01"},
wantTask: &task.Task{
ID: 1,
Title: "Deploy to prod",
Priority: task.PriorityHigh,
DueDate: "2026-03-01",
Status: task.StatusPending,
},
},
{
name: "task with default priority",
args: []string{"add", "Write docs"},
wantTask: &task.Task{
ID: 1,
Title: "Write docs",
Priority: task.PriorityMedium,
Status: task.StatusPending,
},
},
{
name: "missing title argument",
args: []string{"add"},
wantErr: true,
},
{
name: "invalid priority value",
args: []string{"add", "Task", "--priority", "urgent"},
wantErr: true,
},
{
name: "invalid due date format",
args: []string{"add", "Task", "--due", "next-tuesday"},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Use a temp file for each test
tmpDir := t.TempDir()
tmpFile := filepath.Join(tmpDir, "tasks.json")
// Set up root command with temp file flag
rootCmd.SetArgs(append(tt.args, "--file", tmpFile))
err := rootCmd.Execute()
if (err != nil) != tt.wantErr {
t.Fatalf("Execute() error = %v, wantErr %v", err, tt.wantErr)
}
if tt.wantTask != nil {
data, err := os.ReadFile(tmpFile)
if err != nil {
t.Fatalf("reading task file: %v", err)
}
var tasks []task.Task
if err := json.Unmarshal(data, &tasks); err != nil {
t.Fatalf("unmarshaling tasks: %v", err)
}
if len(tasks) != 1 {
t.Fatalf("got %d tasks, want 1", len(tasks))
}
got := tasks[0]
if got.Title != tt.wantTask.Title {
t.Errorf("title = %q, want %q", got.Title, tt.wantTask.Title)
}
if got.Priority != tt.wantTask.Priority {
t.Errorf("priority = %q, want %q", got.Priority, tt.wantTask.Priority)
}
if got.Status != tt.wantTask.Status {
t.Errorf("status = %q, want %q", got.Status, tt.wantTask.Status)
}
}
})
}
}
Integration Tests That Run the Binary
// integration_test.go (build tag: integration)
//go:build integration
package main
import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func TestFullWorkflow(t *testing.T) {
// Build the binary
binary := filepath.Join(t.TempDir(), "taskr")
build := exec.Command("go", "build", "-o", binary, "./cmd/taskr")
if out, err := build.CombinedOutput(); err != nil {
t.Fatalf("build failed: %s\n%s", err, out)
}
taskFile := filepath.Join(t.TempDir(), "tasks.json")
run := func(args ...string) string {
cmd := exec.Command(binary, append(args, "--file", taskFile)...)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("taskr %s failed: %s\n%s", strings.Join(args, " "), err, out)
}
return string(out)
}
// Add a task
out := run("add", "Integration test task", "--priority", "high")
if !strings.Contains(out, "Task added") {
t.Errorf("add output missing confirmation: %s", out)
}
// List tasks
out = run("list")
if !strings.Contains(out, "Integration test task") {
t.Errorf("list output missing task: %s", out)
}
if !strings.Contains(out, "high") {
t.Errorf("list output missing priority: %s", out)
}
// Mark done
out = run("done", "1")
if !strings.Contains(out, "Completed") {
t.Errorf("done output missing confirmation: %s", out)
}
// List with --all to see completed task
out = run("list", "--all")
if !strings.Contains(out, "done") {
t.Errorf("list --all missing done status: %s", out)
}
// Delete
out = run("delete", "1", "--force")
if !strings.Contains(out, "Deleted") {
t.Errorf("delete output missing confirmation: %s", out)
}
// List should be empty
out = run("list")
if !strings.Contains(out, "No tasks found") {
t.Errorf("list after delete should be empty: %s", out)
}
}
Run the tests in your dedicated test tab using Beam split panes. On the left, Claude Code iterates on the code. On the right, you watch the tests:
# Unit tests
$ go test -v ./internal/...
# Integration tests
$ go test -v -tags=integration ./...
The Split-Pane Test Workflow
This is the fastest way to develop a CLI tool. In Beam, split your Claude Code tab and run watchexec -e go -- go test -v ./... in the right pane. Every time Claude saves a file, the tests re-run automatically. You see failures turn green in real time without lifting a finger. This feedback loop is worth the entire setup.
Cross-Compilation and Distribution
One of Go's greatest strengths for CLI tools is that you can compile for any platform from any machine. Claude Code can generate the entire distribution pipeline.
Manual Cross-Compilation
Building for multiple platforms is a single command per target:
# macOS (Apple Silicon)
GOOS=darwin GOARCH=arm64 go build -o dist/taskr-darwin-arm64 ./cmd/taskr
# macOS (Intel)
GOOS=darwin GOARCH=amd64 go build -o dist/taskr-darwin-amd64 ./cmd/taskr
# Linux (amd64)
GOOS=linux GOARCH=amd64 go build -o dist/taskr-linux-amd64 ./cmd/taskr
# Linux (arm64, for Raspberry Pi, AWS Graviton)
GOOS=linux GOARCH=arm64 go build -o dist/taskr-linux-arm64 ./cmd/taskr
# Windows
GOOS=windows GOARCH=amd64 go build -o dist/taskr-windows-amd64.exe ./cmd/taskr
Each binary is self-contained. No runtime, no installer. Users download one file and run it.
GoReleaser for Automated Releases
# .goreleaser.yml
version: 2
project_name: taskr
before:
hooks:
- go mod tidy
- go test ./...
builds:
- main: ./cmd/taskr
env:
- CGO_ENABLED=0
goos:
- darwin
- linux
- windows
goarch:
- amd64
- arm64
ldflags:
- -s -w
- -X github.com/myorg/taskr/internal/cmd.version={{.Version}}
- -X github.com/myorg/taskr/internal/cmd.commit={{.Commit}}
- -X github.com/myorg/taskr/internal/cmd.date={{.Date}}
archives:
- format: tar.gz
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
format_overrides:
- goos: windows
format: zip
files:
- README.md
- LICENSE
checksum:
name_template: "checksums.txt"
changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"
brews:
- repository:
owner: myorg
name: homebrew-tap
homepage: "https://github.com/myorg/taskr"
description: "A fast, local task manager for the command line"
license: "MIT"
install: |
bin.install "taskr"
test: |
system "#{bin}/taskr", "version"
Now release with a single command:
# Tag and release
$ git tag v1.0.0
$ git push origin v1.0.0
# Run GoReleaser (locally or via GitHub Actions)
$ goreleaser release --clean
GoReleaser builds for all platforms, creates archives, generates checksums, publishes a GitHub release with all artifacts, and updates your Homebrew tap — all from that single command.
Homebrew Tap for macOS Users
# .github/workflows/release.yml
name: Release
on:
push:
tags:
- "v*"
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version: "1.22"
- uses: goreleaser/goreleaser-action@v6
with:
version: "~> v2"
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
After this is set up, your users can install with:
$ brew tap myorg/tap
$ brew install taskr
Claude Code generated the GoReleaser config, the Homebrew formula, and the CI/CD workflow. The entire distribution pipeline was built from prompts.
Building TUIs with Bubbletea
For CLI tools that need interactivity beyond simple flags and arguments, Go has Bubbletea — a terminal UI framework based on the Elm architecture. Claude Code can generate full TUI applications with it.
Claude Code generates the full TUI model with the Bubbletea architecture:
// internal/tui/model.go
package tui
import (
"github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/myorg/taskr/internal/task"
)
type model struct {
store *task.Store
tasks []task.Task
cursor int
adding bool
input string
quitting bool
}
func (m model) Init() tea.Cmd {
return nil
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
if m.adding {
return m.handleAddInput(msg)
}
switch msg.String() {
case "q", "ctrl+c":
m.quitting = true
return m, tea.Quit
case "up", "k":
if m.cursor > 0 {
m.cursor--
}
case "down", "j":
if m.cursor < len(m.tasks)-1 {
m.cursor++
}
case "enter":
m.toggleStatus()
case "a":
m.adding = true
case "d":
m.deleteSelected()
}
}
return m, nil
}
func (m model) View() string {
if m.quitting {
return ""
}
header := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#00ADD8")).
Render("taskr") + "\n\n"
var rows string
for i, t := range m.tasks {
cursor := " "
if i == m.cursor {
cursor = "> "
}
status := "[ ]"
if t.Status == task.StatusDone {
status = "[x]"
}
row := fmt.Sprintf("%s%s %s %s %s",
cursor, status, t.Title,
colorPriority(t.Priority), t.DueDate)
rows += row + "\n"
}
help := "\n j/k: navigate enter: toggle a: add d: delete q: quit"
return header + rows + help
}
Bubbletea TUIs compile to the same single binary. No additional runtime dependencies. Users get an interactive experience that feels like a native application — all from the terminal.
This is particularly powerful with Claude Code because the Elm architecture (Model, Update, View) maps cleanly to natural language descriptions. You describe the behavior, and Claude generates the state machine.
The Complete Build Workflow in Beam
Here is the full development workflow, from idea to published CLI, organized in a single Beam workspace:
- Create a Beam workspace — Name it after your CLI project. Set up three tabs: Claude Code, build/run, tests.
- Scaffold with Claude Code — One prompt generates the full Cobra project structure, including go.mod, commands, and the storage layer.
- Build commands iteratively — Prompt Claude Code for each command. Build and test in the side pane after each one.
- Add polish — Colors, config file support, shell completions, and version info — all generated by Claude Code.
- Write tests — Table-driven unit tests and integration tests that run the actual binary. Watch them pass in real time.
- Set up distribution — GoReleaser config, GitHub Actions workflow, Homebrew tap — all generated from prompts.
- Release — Tag, push, and GoReleaser handles the rest. Binaries for every platform, published to GitHub and Homebrew.
Every step happens in the terminal. Every step is accelerated by Claude Code. And Beam keeps the entire workflow organized so you never lose context.
Build Your Next CLI Tool with Beam
Download Beam free and set up the perfect workspace for Go CLI development. Claude Code on the left, your CLI output on the right, tests running in the background. Everything in one place.
Download Beam for macOSSummary
Go is the dominant language for CLI tools because of single-binary distribution, cross-compilation, fast startup, and a rich ecosystem of libraries like Cobra, Viper, Lipgloss, and Bubbletea. Claude Code makes building Go CLIs dramatically faster by generating scaffolding, commands, tests, and distribution infrastructure from natural language prompts.
Here is what we covered:
- Why Go for CLIs — Single binary, cross-compilation, fast startup, and the same architecture behind Docker, Kubernetes, and GitHub CLI
- Beam workspace setup — Three tabs (Claude Code, build/run, tests) with split panes for live iteration
- Cobra scaffolding — Claude Code generates the full project structure, root command, and module initialization in one prompt
- Building commands — Add, list, done, and delete commands with proper flags, argument validation, and error handling
- Polish — Colored output with Lipgloss, Viper config files, shell completions, and version info with ldflags
- Testing — Table-driven tests for every command and integration tests that exercise the real binary
- Distribution — Cross-compilation, GoReleaser, GitHub Actions, and Homebrew tap — all generated from prompts
- TUIs with Bubbletea — Interactive terminal interfaces compiled into the same single binary
The combination of Go's compile-to-binary simplicity, Cobra's industry-standard command framework, Claude Code's ability to generate idiomatic Go from descriptions, and Beam's organized workspace creates the fastest possible path from idea to published CLI tool.
Happy building.