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.
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):
- Command-line flags
- Environment variables
- Project-local config (
.mytool.yaml in current directory or parents)
- User config (
~/.config/mytool/config.yaml or ~/.mytool.yaml)
- System config (
/etc/mytool/config.yaml)
- 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()
}
|
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:
- Look for credentials in environment variables (
MYTOOL_TOKEN)
- Check
~/.config/mytool/credentials (not version-controlled)
- Support keychain/keyring integration
- 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:
- What happened — the action that failed
- Why it failed — the underlying cause (the actual error)
- 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
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
Output
Configuration
Shell integration
Error handling
Distribution
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