LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

Writing CLI Tools with Great UX: The Complete Guide

cligorustuxdevtoolsgoreleasercobra

Writing CLI Tools with Great UX: The Complete Guide

A CLI tool is a user interface. The user is a developer, the interface is text, and the medium is a terminal. But the fundamental design problem — how do you make something easy and pleasant to use? — is the same as for any other interface.

Most CLI tools fail not because they lack features, but because they ignore the conventions and ergonomics that make tools feel native to the terminal. This guide covers the full stack: API design, argument parsing, output formatting, configuration, shell completions, and distribution.


Choosing a Language and Framework

The two dominant ecosystems for CLI tools today are Go and Rust. Both produce fast, single-binary tools that install without runtime dependencies.

Go

Go produces fast-to-compile, single-binary tools. The ecosystem for CLI development is mature:

  • cobra — the dominant CLI framework (used by kubectl, helm, docker, gh)
  • bubbletea — TUI framework using the Elm architecture
  • lipgloss — style library for terminal output
  • viper — configuration management (integrates with cobra)
  • survey/huh — interactive prompts
1
2
3
4
5
6
7
8
9
// go.mod
module github.com/you/mytool

go 1.23

require (
    github.com/spf13/cobra v1.8.1
    github.com/spf13/viper v1.19.0
)

Rust

Rust produces smaller, faster binaries with excellent error handling. The ecosystem:

  • clap — the dominant CLI framework, derive-based
  • ratatui — TUI framework (successor to tui-rs)
  • indicatif — progress bars
  • dialoguer — interactive prompts
  • serde/serde_json — serialization for structured output
1
2
3
4
5
# Cargo.toml
[dependencies]
clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"

Argument Design: The Core API

Before writing code, design your argument API. The mental model should match how users think about the task.

Subcommands for Multiple Actions

Use subcommands when your tool does conceptually distinct things:

mytool deploy --env production --image v1.2.3
mytool status --env production
mytool rollback --env production --to v1.1.0
mytool logs --env production --tail 100

This is the git / kubectl / docker pattern. Users learn subcommands incrementally — they don’t need to understand the whole tool to use one part.

Avoid designing a tool where you need to read the entire help text before you can do anything useful.

Arguments vs Flags

Positional arguments are for the primary target of the action — the thing you’re operating on. They should be required when obvious:

# Good: positional argument for the file being operated on
mytool encode input.json
mytool deploy myapp

# Awkward: flag for something that's always required
mytool encode --input input.json

Flags are for modifiers that change how the action works. They should have sensible defaults:

# --format has a sensible default (text)
mytool list
mytool list --format json
mytool list --format table

# --env might not have a sensible default
mytool deploy --env production

Boolean flags should be positive. --verbose not --no-quiet. --dry-run not --run. When you need to negate, --no-verify is acceptable if the positive form is the implicit default.

Short Flags

Reserve single-character flags for the most common options. Establish conventions:

  • -v for verbose
  • -o for output file
  • -n for dry-run (from make -n)
  • -f for force (use carefully)
  • -q for quiet

Don’t define short flags for everything — reserved characters run out, and obscure short flags are worse than no short flags.


Implementing with Cobra (Go)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// cmd/root.go
package cmd

import (
    "fmt"
    "os"

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

var rootCmd = &cobra.Command{
    Use:   "mytool",
    Short: "A tool that does useful things",
    Long: `mytool manages deployments across environments.

Documentation: https://mytool.example.com
`,
}

var cfgFile string
var verbose bool

func init() {
    cobra.OnInitialize(initConfig)
    rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default: $HOME/.mytool.yaml)")
    rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "enable verbose output")
    viper.BindPFlag("verbose", rootCmd.PersistentFlags().Lookup("verbose"))
}

func initConfig() {
    if cfgFile != "" {
        viper.SetConfigFile(cfgFile)
    } else {
        home, _ := os.UserHomeDir()
        viper.AddConfigPath(home)
        viper.AddConfigPath(".")
        viper.SetConfigName(".mytool")
        viper.SetConfigType("yaml")
    }
    viper.AutomaticEnv()
    viper.SetEnvPrefix("MYTOOL")  // MYTOOL_VERBOSE, MYTOOL_TOKEN, etc.
    viper.ReadInConfig()
}

func Execute() {
    if err := rootCmd.Execute(); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// cmd/deploy.go
package cmd

import (
    "fmt"
    "github.com/spf13/cobra"
)

var deployCmd = &cobra.Command{
    Use:   "deploy APP",
    Short: "Deploy an application",
    Args:  cobra.ExactArgs(1),
    RunE:  runDeploy,
}

var deployEnv string
var deployImage string
var deployDryRun bool

func init() {
    rootCmd.AddCommand(deployCmd)
    deployCmd.Flags().StringVarP(&deployEnv, "env", "e", "", "target environment (required)")
    deployCmd.Flags().StringVar(&deployImage, "image", "", "image tag to deploy")
    deployCmd.Flags().BoolVarP(&deployDryRun, "dry-run", "n", false, "print actions without executing")
    deployCmd.MarkFlagRequired("env")
}

func runDeploy(cmd *cobra.Command, args []string) error {
    app := args[0]
    if deployDryRun {
        fmt.Printf("[dry-run] would deploy %s to %s\n", app, deployEnv)
        return nil
    }
    // ... actual logic
    return nil
}

Implementing with Clap (Rust)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
// src/main.rs
use clap::{Parser, Subcommand, Args};

#[derive(Parser)]
#[command(
    name = "mytool",
    about = "A tool that does useful things",
    version,
    long_about = "mytool manages deployments across environments.\n\nDocumentation: https://mytool.example.com"
)]
struct Cli {
    #[command(subcommand)]
    command: Commands,

    /// Enable verbose output
    #[arg(short, long, global = true)]
    verbose: bool,

    /// Config file path
    #[arg(long, global = true)]
    config: Option<String>,
}

#[derive(Subcommand)]
enum Commands {
    /// Deploy an application
    Deploy(DeployArgs),
    /// Show deployment status
    Status(StatusArgs),
}

#[derive(Args)]
struct DeployArgs {
    /// Application name
    app: String,

    /// Target environment
    #[arg(short, long)]
    env: String,

    /// Image tag to deploy
    #[arg(long)]
    image: Option<String>,

    /// Print actions without executing
    #[arg(short = 'n', long)]
    dry_run: bool,
}

fn main() {
    let cli = Cli::parse();

    match cli.command {
        Commands::Deploy(args) => run_deploy(args, cli.verbose),
        Commands::Status(args) => run_status(args, cli.verbose),
    }
}

Output: Text, JSON, and Tables

CLI output is consumed in two modes: by humans reading in a terminal, and by scripts piping to other tools. Great tools support both.

The –format Flag

Always support --format json for machine-readable output:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
type DeployResult struct {
    App     string `json:"app"`
    Env     string `json:"env"`
    Version string `json:"version"`
    Status  string `json:"status"`
}

func outputResult(result DeployResult, format string) error {
    switch format {
    case "json":
        return json.NewEncoder(os.Stdout).Encode(result)
    case "yaml":
        return yaml.NewEncoder(os.Stdout).Encode(result)
    default:
        fmt.Printf("Deployed %s to %s (%s)\n", result.App, result.Env, result.Version)
        return nil
    }
}

When stdout is not a terminal (i.e., it’s being piped), you should ideally default to a simpler output format automatically:

1
2
3
4
5
import "golang.org/x/term"

func isTerminal() bool {
    return term.IsTerminal(int(os.Stdout.Fd()))
}

Tables

For listing resources, a table is more readable than JSON:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
import "github.com/olekukonko/tablewriter"

func printTable(deploys []Deploy) {
    table := tablewriter.NewWriter(os.Stdout)
    table.SetHeader([]string{"App", "Env", "Version", "Status", "Age"})
    table.SetBorder(false)
    table.SetColumnSeparator("  ")
    table.SetHeaderAlignment(tablewriter.ALIGN_LEFT)
    table.SetAlignment(tablewriter.ALIGN_LEFT)

    for _, d := range deploys {
        table.Append([]string{
            d.App,
            d.Env,
            d.Version,
            colorStatus(d.Status),
            humanize.Time(d.UpdatedAt),
        })
    }
    table.Render()
}

Colors and Terminal Detection

Use colors for status (green=success, red=error, yellow=warning), but only when outputting to a terminal. Never write ANSI escape codes to a pipe.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
import "github.com/fatih/color"

var (
    green  = color.New(color.FgGreen).SprintFunc()
    red    = color.New(color.FgRed).SprintFunc()
    yellow = color.New(color.FgYellow).SprintFunc()
)

// color.NoColor is automatically set when stdout is not a terminal
func colorStatus(s string) string {
    switch s {
    case "running":
        return green(s)
    case "error":
        return red(s)
    case "pending":
        return yellow(s)
    default:
        return s
    }
}

Stderr for Errors and Progress

Errors and progress indicators go to stderr. Output (data) goes to stdout. This is the Unix convention and it matters: users can redirect stdout to a file or pipe while still seeing progress on their terminal.

1
2
3
4
5
6
7
8
// Errors to stderr
fmt.Fprintln(os.Stderr, "Error: connection refused")

// Progress to stderr
fmt.Fprintf(os.Stderr, "Deploying %s...\n", app)

// Result to stdout
fmt.Println(resultJSON)

Progress Bars and Spinners

For operations that take more than a second or two, show progress. Users abandon tools that appear frozen.

Go: Bubbletea or plain indicatif style

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import "github.com/charmbracelet/bubbles/spinner"

// Simple text spinner for a one-liner
import (
    "fmt"
    "time"
    "github.com/briandowns/spinner"
)

func deployWithSpinner(app, env string) error {
    s := spinner.New(spinner.CharSets[14], 80*time.Millisecond, spinner.WithWriter(os.Stderr))
    s.Suffix = fmt.Sprintf(" Deploying %s to %s...", app, env)
    s.Start()
    defer s.Stop()

    err := deploy(app, env)
    if err != nil {
        s.FinalMSG = fmt.Sprintf("✗ Deploy failed: %v\n", err)
        return err
    }
    s.FinalMSG = fmt.Sprintf("✓ Deployed %s to %s\n", app, env)
    return nil
}

Rust: indicatif

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
use indicatif::{ProgressBar, ProgressStyle, MultiProgress};
use std::time::Duration;

fn deploy_with_progress(app: &str, env: &str) -> anyhow::Result<()> {
    let pb = ProgressBar::new_spinner();
    pb.set_style(
        ProgressStyle::default_spinner()
            .template("{spinner:.green} {msg}")?
    );
    pb.set_message(format!("Deploying {} to {}...", app, env));
    pb.enable_steady_tick(Duration::from_millis(80));

    let result = do_deploy(app, env);

    pb.finish_with_message(match &result {
        Ok(_) => format!("✓ Deployed {} to {}", app, env),
        Err(e) => format!("✗ Deploy failed: {}", e),
    });

    result
}

// Progress bar for known total
fn download_with_progress(urls: &[String]) -> anyhow::Result<()> {
    let pb = ProgressBar::new(urls.len() as u64);
    pb.set_style(
        ProgressStyle::default_bar()
            .template("[{elapsed_precise}] {bar:40.cyan/blue} {pos}/{len} {msg}")?
    );

    for url in urls {
        download_file(url)?;
        pb.inc(1);
        pb.set_message(format!("downloaded {}", url));
    }
    pb.finish_with_message("done");
    Ok(())
}

Configuration Conventions

Follow the XDG Base Directory spec and platform conventions.

Configuration Search Order

A well-behaved CLI tool looks for configuration in this order (highest to lowest priority):

  1. Command-line flags
  2. Environment variables
  3. Project-local config (.mytool.yaml in current directory or parents)
  4. User config (~/.config/mytool/config.yaml or ~/.mytool.yaml)
  5. System config (/etc/mytool/config.yaml)
  6. Built-in defaults
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
func initConfig() {
    // 1. Explicit flag
    if cfgFile != "" {
        viper.SetConfigFile(cfgFile)
        viper.ReadInConfig()
        return
    }

    // 2. Environment variable override
    viper.AutomaticEnv()
    viper.SetEnvPrefix("MYTOOL")

    // 3. Project-local config
    viper.AddConfigPath(".")

    // 4. User config (XDG)
    if xdgConfig := os.Getenv("XDG_CONFIG_HOME"); xdgConfig != "" {
        viper.AddConfigPath(filepath.Join(xdgConfig, "mytool"))
    } else {
        home, _ := os.UserHomeDir()
        viper.AddConfigPath(filepath.Join(home, ".config", "mytool"))
        viper.AddConfigPath(home)
    }

    // 5. System config
    viper.AddConfigPath("/etc/mytool")

    viper.SetConfigName("config")
    viper.SetConfigType("yaml")
    viper.ReadInConfig()
}

Config File Format

YAML is the most readable format for config files. TOML is a reasonable alternative. JSON is machine-readable but annoying to edit by hand (no comments). Avoid INI for complex config.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# ~/.config/mytool/config.yaml
default_env: staging

environments:
  production:
    api_url: https://api.prod.example.com
    timeout: 30s
  staging:
    api_url: https://api.staging.example.com
    timeout: 10s

auth:
  token: ${MYTOOL_TOKEN}  # support env var interpolation

output:
  format: table
  colors: auto  # auto | always | never

Credential Storage

Never store credentials in your config file by default. Instead:

  1. Look for credentials in environment variables (MYTOOL_TOKEN)
  2. Check ~/.config/mytool/credentials (not version-controlled)
  3. Support keychain/keyring integration
  4. Support the operating system credential store
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
import "github.com/zalando/go-keyring"

func getToken(env string) (string, error) {
    // 1. Environment variable
    if token := os.Getenv("MYTOOL_TOKEN"); token != "" {
        return token, nil
    }

    // 2. OS keychain
    token, err := keyring.Get("mytool", env)
    if err == nil {
        return token, nil
    }

    // 3. Credentials file
    return readCredentialsFile(env)
}

func storeToken(env, token string) error {
    return keyring.Set("mytool", env, token)
}

Shell Completions

Shell completions are what separate tools that feel native from tools that feel like they were ported from somewhere else. Every modern CLI tool should provide completions for bash, zsh, and fish.

Cobra: Automatic Completions

Cobra generates completions automatically based on your command structure. Users can generate and install them:

1
2
3
4
# Generate completions
mytool completion bash > /etc/bash_completion.d/mytool
mytool completion zsh > "${fpath[1]}/_mytool"
mytool completion fish > ~/.config/fish/completions/mytool.fish

For dynamic completions (completing resource names from an API), register completion functions:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
var deployCmd = &cobra.Command{
    Use:   "deploy APP",
    ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
        apps, err := listApps()
        if err != nil {
            return nil, cobra.ShellCompDirectiveError
        }
        return apps, cobra.ShellCompDirectiveNoFileComp
    },
    RunE: runDeploy,
}

func init() {
    deployCmd.RegisterFlagCompletionFunc("env", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
        return []string{"production", "staging", "dev"}, cobra.ShellCompDirectiveDefault
    })
}

Clap: Shell Completions

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// build.rs — generate completions at build time
use clap::CommandFactory;
use clap_complete::{generate_to, Shell};

fn main() {
    let out_dir = std::env::var("OUT_DIR").unwrap();
    let mut cmd = Cli::command();

    for shell in [Shell::Bash, Shell::Zsh, Shell::Fish] {
        generate_to(shell, &mut cmd, "mytool", &out_dir).unwrap();
    }
}

Or generate at runtime:

1
2
3
4
Commands::Completions { shell } => {
    let mut cmd = Cli::command();
    clap_complete::generate(shell, &mut cmd, "mytool", &mut std::io::stdout());
}

Error Messages

Error messages are documentation. A user encountering an error is in a difficult moment — help them recover.

The Anatomy of a Good Error Message

Error: failed to deploy myapp to production
  → connection refused at https://api.prod.example.com:8443

Possible causes:
  • VPN not connected (check with: mytool check-connectivity)
  • Wrong API URL in config (current: https://api.prod.example.com:8443)

To diagnose: run with --verbose for full request details

Components:

  1. What happened — the action that failed
  2. Why it failed — the underlying cause (the actual error)
  3. What to do — suggested next steps

Never just wrap the underlying error verbatim. connection refused (os error 111) helps no one.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// Wrap errors with context as you propagate up
func deploy(app, env string) error {
    config, err := loadEnvConfig(env)
    if err != nil {
        return fmt.Errorf("deploy %s to %s: load config: %w", app, env, err)
    }

    if err := config.API.Deploy(app); err != nil {
        return fmt.Errorf("deploy %s to %s: API call to %s: %w", app, env, config.API.URL, err)
    }
    return nil
}

// At the top level, format the error for humans
func runDeploy(cmd *cobra.Command, args []string) error {
    err := deploy(args[0], deployEnv)
    if err != nil {
        // cobra will print the error, but we can improve the message
        var connErr *ConnectError
        if errors.As(err, &connErr) {
            fmt.Fprintf(os.Stderr, "\nError: %v\n\n", err)
            fmt.Fprintf(os.Stderr, "Possible causes:\n")
            fmt.Fprintf(os.Stderr, "  • VPN not connected\n")
            fmt.Fprintf(os.Stderr, "  • Wrong API URL: %s\n\n", connErr.URL)
            fmt.Fprintf(os.Stderr, "Run with --verbose for full details\n")
            os.Exit(1)
        }
        return err
    }
    return nil
}

Exit Codes

Use meaningful exit codes:

  • 0 — success
  • 1 — general error (default for most errors)
  • 2 — usage error (bad arguments, missing required flags)
  • 3 — not found (resource doesn’t exist)
  • 4 — permission denied
  • 5 — timeout

Document your exit codes in the help text and man page.


Interactive Prompts

For destructive operations or missing required inputs, prompt interactively. But: only prompt when stdin is a terminal. Never block a pipeline waiting for input.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import "github.com/charmbracelet/huh"

func confirmDestroy(app, env string) (bool, error) {
    if !term.IsTerminal(int(os.Stdin.Fd())) {
        return false, fmt.Errorf("cannot prompt in non-interactive mode; use --force to skip confirmation")
    }

    var confirmed bool
    form := huh.NewForm(
        huh.NewGroup(
            huh.NewConfirm().
                Title(fmt.Sprintf("Destroy %s in %s?", app, env)).
                Description("This action cannot be undone.").
                Value(&confirmed),
        ),
    )
    return confirmed, form.Run()
}

Always provide a --force or --yes flag to skip prompts for automation:

1
2
3
4
5
6
7
if !forceFlag {
    confirmed, err := confirmDestroy(app, env)
    if err != nil || !confirmed {
        fmt.Fprintln(os.Stderr, "Aborted.")
        os.Exit(1)
    }
}

Distribution with GoReleaser

GoReleaser automates building and releasing Go binaries for multiple platforms.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
# .goreleaser.yaml
version: 2

before:
  hooks:
    - go mod tidy
    - go generate ./...

builds:
  - env:
      - CGO_ENABLED=0
    goos:
      - linux
      - windows
      - darwin
    goarch:
      - amd64
      - arm64
    ldflags:
      - -s -w
      - -X main.version={{.Version}}
      - -X main.commit={{.Commit}}
      - -X main.date={{.Date}}
    flags:
      - -trimpath

archives:
  - format: tar.gz
    format_overrides:
      - goos: windows
        format: zip
    name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
    files:
      - LICENSE
      - README.md
      - completions/*

checksum:
  name_template: "checksums.txt"

snapshot:
  name_template: "{{ incpatch .Version }}-next"

changelog:
  sort: asc
  filters:
    exclude:
      - "^docs:"
      - "^test:"
      - "^chore:"

# Homebrew tap
brews:
  - repository:
      owner: your-org
      name: homebrew-tools
    homepage: "https://github.com/your-org/mytool"
    description: "A tool that does useful things"
    install: |
      bin.install "mytool"
      bash_completion.install "completions/mytool.bash" => "mytool"
      zsh_completion.install "completions/mytool.zsh" => "_mytool"
      fish_completion.install "completions/mytool.fish"

# Linux packages
nfpms:
  - id: packages
    package_name: mytool
    formats:
      - deb
      - rpm
    maintainer: "Your Name <you@example.com>"
    description: "A tool that does useful things"
    license: MIT
    contents:
      - src: completions/mytool.bash
        dst: /usr/share/bash-completion/completions/mytool
      - src: completions/mytool.zsh
        dst: /usr/share/zsh/vendor-completions/_mytool
      - src: completions/mytool.fish
        dst: /usr/share/fish/vendor_completions.d/mytool.fish

# Docker image
dockers:
  - image_templates:
      - "ghcr.io/your-org/mytool:{{ .Tag }}"
      - "ghcr.io/your-org/mytool:latest"
    dockerfile: Dockerfile
    use: buildx
    build_flag_templates:
      - "--platform=linux/amd64,linux/arm64"

# Sign artifacts with cosign
signs:
  - cmd: cosign
    artifacts: checksum
    output: true
    args:
      - sign-blob
      - "--output-certificate=${certificate}"
      - "--output-signature=${signature}"
      - "${artifact}"
      - "--yes"

Trigger a release from CI:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# .github/workflows/release.yml
on:
  push:
    tags:
      - 'v*'

jobs:
  release:
    runs-on: ubuntu-latest
    permissions:
      contents: write
      packages: write
      id-token: write  # for cosign keyless signing

    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0  # GoReleaser needs git history for changelog

      - uses: actions/setup-go@v5
        with:
          go-version: '1.23'

      - name: Install cosign
        uses: sigstore/cosign-installer@v3

      - name: Run GoReleaser
        uses: goreleaser/goreleaser-action@v6
        with:
          version: latest
          args: release --clean
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

cargo-dist for Rust

The Rust equivalent is cargo-dist:

1
2
3
4
5
6
7
8
# Cargo.toml
[workspace.metadata.dist]
cargo-dist-version = "0.22.1"
ci = "github"
installers = ["shell", "powershell", "homebrew", "msi"]
targets = ["aarch64-apple-darwin", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"]
tap = "your-org/homebrew-tools"
publish-jobs = ["homebrew"]

Initialize and generate CI config:

1
2
3
cargo dist init
cargo dist generate
# Generates .github/workflows/release.yml

Users install via:

1
2
3
4
5
6
7
8
# Homebrew
brew install your-org/tools/mytool

# Shell installer (detects platform)
curl --proto '=https' --tlsv1.2 -LsSf https://github.com/your-org/mytool/releases/latest/download/mytool-installer.sh | sh

# Cargo
cargo install mytool

The Help Text Contract

Help text is the first thing users read. Make it count.

Structure of Good Help

mytool deploy APP

Deploy an application to a target environment.

The deployment waits for the rollout to complete and exits
with code 1 if the deployment fails or times out.

Usage:
  mytool deploy APP [flags]

Examples:
  # Deploy to staging
  mytool deploy myapp --env staging

  # Deploy a specific image tag
  mytool deploy myapp --env production --image v1.2.3

  # Dry run to see what would happen
  mytool deploy myapp --env production --dry-run

Flags:
  -e, --env string     Target environment (required)
      --image string   Image tag to deploy (default: latest)
  -n, --dry-run        Print actions without executing
  -h, --help           Help for deploy

Global Flags:
  -v, --verbose        Enable verbose output
      --config string  Config file (default: ~/.config/mytool/config.yaml)

See Also:
  mytool status - Check deployment status
  mytool rollback - Roll back to a previous version

Key elements:

  • One-line short description (used in mytool --help listing)
  • Longer description explaining behavior and edge cases
  • Examples first — users read examples before flags
  • All flags documented with defaults
  • Cross-references to related commands

Version Information

Always implement --version:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// Set at build time via ldflags
var (
    version = "dev"
    commit  = "unknown"
    date    = "unknown"
)

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

A CLI UX Checklist

Before shipping:

Argument design

  • Subcommands for distinct actions
  • Positional args for primary targets, flags for modifiers
  • Required flags explicitly marked
  • Sensible defaults for optional flags

Output

  • --format json supported
  • Colors disabled when not outputting to a terminal
  • Errors to stderr, data to stdout
  • Progress indicators for long operations

Configuration

  • Reads from env vars (MYTOOL_*)
  • Config file follows XDG conventions
  • Credentials never stored in main config file
  • --config flag to override config location

Shell integration

  • completion bash/zsh/fish subcommand
  • Dynamic completions for resource names where applicable

Error handling

  • Error messages explain what failed and why
  • Suggested remediation for common errors
  • Meaningful exit codes

Distribution

  • Binaries for Linux (amd64, arm64), macOS (Intel, Apple Silicon), Windows
  • Homebrew formula
  • .deb/.rpm packages for Linux
  • --version that shows version, commit, and build date
  • Completions bundled in release archives

The best CLI tools feel like they’ve always been there. They complete your thoughts, fail clearly, and stay out of the way. That’s not magic — it’s following the conventions above and treating the user’s time as precious.

Comments