Download Beam

How to Build a CLI Tool with Claude Code and Go

February 2026 • 14 min read

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.

Beam — taskr CLI Claude Code go build go test $ 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 3 Refactor auth module high 2026-02-12 done 4 Add unit tests low 2026-02-20 pending 5 Deploy to staging medium 2026-02-15 pending 5 tasks: 1 done , 4 pending $ taskr add "Review PR #42" --priority high --due 2026-02-13 ✓ Task added: "Review PR #42" (id: 6) $

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:

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:

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:

  1. Ask Claude Code to add a new command or fix a bug on the left
  2. Build and run the CLI on the right to see the result instantly
  3. 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.

Create a Go CLI called "taskr" using Cobra. It should have commands: add, list, done, and delete. Use a local JSON file (~/.taskr.json) for storage. Initialize go.mod as github.com/myorg/taskr with Go 1.22. Include a proper project structure with cmd/taskr/main.go as the entrypoint and internal/cmd/ for command files.

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

Implement the add command. It should accept a task title as an argument and have flags for --priority (high, medium, low; default medium) and --due (date string in YYYY-MM-DD format, optional). Auto-increment the task ID. Print a success message with the task ID after adding.
// 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

Implement the list command. Display tasks in a formatted table with columns for ID, Task, Priority, Due, and Status. Use text/tabwriter for alignment. Add a --all flag that includes completed tasks (by default, only show pending tasks). Add a summary line at the bottom showing total counts.
// 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

Implement the done command. It accepts one or more task IDs as arguments and marks them as completed. Print a success message for each. Return an error if any ID is not found.
// 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

Implement the delete command. It accepts a task ID and removes the task. Add a --force flag that skips the confirmation prompt. Without --force, ask the user to confirm deletion by typing y/n.
// 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

Add colored output to the list command using charmbracelet/lipgloss. Color priority levels: high in red, medium in yellow, low in blue. Color the status: done in green, pending in yellow. Use bold white for task titles.
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

Add Viper configuration support. Read from ~/.taskr.yaml for default settings. Support config keys: default_priority (string), file_path (string for the JSON store location), and color (bool to enable/disable colored output). Config values should be overridable by flags and environment variables with prefix TASKR_.
// 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

Add a completion command that generates shell completion scripts for bash, zsh, fish, and powershell. Use Cobra's built-in completion generation. Also add custom completions for the --priority flag so it suggests high, medium, and low.
// 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

Add a version command that displays the version, commit hash, and build date. Use ldflags to inject these values at build time.
// 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

Write table-driven tests for the add command. Cover cases: valid task with all flags, task with default priority, missing title argument, invalid priority value, and invalid due date format. Use a temporary file for the store so tests do not affect real data.
// 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

Write integration tests that build the binary and run it as a subprocess. Test the full workflow: add a task, list tasks (verify it appears), mark it done, list again (verify status changed), delete it, list again (verify it is gone). Use os/exec to 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

Generate a .goreleaser.yml configuration for taskr. Build for darwin/amd64, darwin/arm64, linux/amd64, linux/arm64, and windows/amd64. Include ldflags for version, commit, and date. Generate checksums. Create a Homebrew formula in a tap repository. Create archives with README and LICENSE included.
# .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

Generate a GitHub Actions workflow that runs GoReleaser on every new tag. It should build all platform binaries, create a GitHub release, and automatically update the Homebrew tap.
# .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.

Add an interactive mode to taskr. When the user runs "taskr" with no arguments, show a Bubbletea TUI that displays the task list. Let users navigate with arrow keys, press Enter to toggle done/pending, press "a" to add a new task (with inline input), press "d" to delete, and "q" to quit. Use Lipgloss for styling.

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:

  1. Create a Beam workspace — Name it after your CLI project. Set up three tabs: Claude Code, build/run, tests.
  2. Scaffold with Claude Code — One prompt generates the full Cobra project structure, including go.mod, commands, and the storage layer.
  3. Build commands iteratively — Prompt Claude Code for each command. Build and test in the side pane after each one.
  4. Add polish — Colors, config file support, shell completions, and version info — all generated by Claude Code.
  5. Write tests — Table-driven unit tests and integration tests that run the actual binary. Watch them pass in real time.
  6. Set up distribution — GoReleaser config, GitHub Actions workflow, Homebrew tap — all generated from prompts.
  7. 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 macOS

Summary

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:

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.