CLI Package
A lightweight framework for building command-line applications with consistent behavior.
Overview
The CLI package provides a standardized way to create command-line applications with common features:
- Consistent flag parsing using
pflag
- Structured logging with
log/slog
- Automatic version information handling
- Shell completion generation (Bash, Zsh, Fish)
- Custom completion extension point
- Error handling utilities
- Builder pattern for configuration
- Stream configuration (stdin, stdout, stderr)
Table of Contents
Quick Start
Here's a simple example of how to use the CLI package:
package main
import (
"context"
"fmt"
"os"
flag "github.com/spf13/pflag"
"git.sr.ht/~jcmuller/tools/pkg/cli"
)
func main() {
var name string
app := cli.New(
"hello-world",
"A simple hello world application\n\nUsage:\n %s [options]\n\nOptions:\n",
cli.WithRunFunc(run),
cli.WithFlags(func(fs *flag.FlagSet) {
fs.StringVar(&name, "name", "World", "Name to greet")
}),
)
if err := app.Run(context.Background(), os.Args); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func run(app *cli.App) error {
name := // ... get from flag variable
fmt.Printf("Hello, %s!\n", name)
return nil
}
Features
Builder Pattern
The CLI package uses the builder pattern for configuration:
app := cli.New(
"app-name",
"app description",
cli.WithRunFunc(run),
cli.WithFlags(addFlags),
cli.WithVersion("1.0.0"), // Optional, defaults to version from tools package
cli.WithLogConfig(logConfig), // Optional, defaults to text output to stderr
cli.WithRequiredArguments(1), // Optional, minimum number of arguments
cli.WithCustomCompletion(customComp), // Optional, custom completion logic
cli.WithStreams(stdin, stdout, stderr), // Optional, for testing
)
Common Flags
All applications automatically get these flags:
-d, --debug: Enable debug logging
-h, --help: Show help and exit
-v, --version: Show version and exit
-c, --completion <shell>: Generate shell completion script (bash, zsh, fish)
You can add custom flags using the WithFlags option:
var (
configFile string
verbose bool
count int
)
cli.WithFlags(func(fs *flag.FlagSet) {
fs.StringVarP(&configFile, "config", "c", "", "Config file path")
fs.BoolVarP(&verbose, "verbose", "v", false, "Verbose output")
fs.IntVar(&count, "count", 10, "Number of items")
})
Structured Logging
The CLI package uses log/slog for structured logging:
// Configure logging with options
config := cli.NewLogConfig(
cli.WithLogLevel(slog.LevelDebug),
cli.WithLogFormat("json"),
cli.WithLogOutput(customWriter),
)
// Use in your application
app := cli.New(
"app-name",
"description",
cli.WithLogConfig(config),
)
The --debug flag automatically sets the log level to slog.LevelDebug.
In your run function, use structured logging:
func run(app *cli.App) error {
ctx := app.Context()
slog.InfoContext(ctx, "Starting operation", "user", username, "count", 42)
slog.DebugContext(ctx, "Debug information", "key", "value")
if err != nil {
slog.ErrorContext(ctx, "Operation failed", "error", err)
return err
}
return nil
}
Shell Completion
The CLI package provides automatic shell completion generation for Bash, Zsh, and Fish shells. Flags are completed automatically.
Persistent Installation
For persistent installation, save the completion script to the appropriate directory for your shell.
The generated scripts are self-updating, meaning they will automatically query the binary for the latest flags whenever completions are requested:
# Bash completion
$ your-app --completion=bash > ~/.bash_completion.d/your-app
# Zsh completion
$ your-app --completion=zsh > ~/.local/share/zsh/functions/_your-app
# Fish completion
$ your-app --completion=fish > ~/.config/fish/completions/your-app.fish
With this approach, you won't need to regenerate completion scripts when your application's flags change.
Dynamic Loading
For temporary use or testing, you can dynamically load the completion in your current shell session:
# Zsh dynamic loading
$ eval "$(DYNAMIC_COMPLETION=1 your-app --completion=zsh)"
# Bash dynamic loading
$ eval "$(DYNAMIC_COMPLETION=1 your-app --completion=bash)"
# Fish dynamic loading (in fish shell)
$ DYNAMIC_COMPLETION=1 your-app --completion=fish | source
The completion scripts will provide tab completion for all flags defined in your application.
Custom Completions
For applications that need to complete arguments beyond flags (like subcommands, file paths, or dynamic values), you can provide custom completion logic:
func customCompletion(shell cli.ShellType) (string, error) {
switch shell {
case cli.Zsh:
return generateZshCompletion()
case cli.Bash:
return generateBashCompletion()
case cli.Fish:
return generateFishCompletion()
default:
return "", nil
}
}
func generateZshCompletion() (string, error) {
// Return additional zsh completion code
// This code is injected after the flag completions
return ` '*::arg:->args' && return 0
case $state in
args)
# Your custom completion logic here
local -a subcommands
subcommands=('foo:Do foo' 'bar:Do bar')
_describe 'subcommands' subcommands
;;
esac
`, nil
}
// Use it in your app
app := cli.New(
"app-name",
"description",
cli.WithCustomCompletion(customCompletion),
)
Complete Example: See ff/main.go for a real-world example that completes dynamic subcommands.
The custom completion function receives the shell type and should return shell-specific completion code. The CLI package handles:
- Flag completion (automatic via
_arguments in zsh, compgen in bash, complete in fish)
- Integration of your custom logic into the completion script
Your custom logic should handle:
- Positional arguments
- Subcommands
- File paths or other dynamic completions
Version information is handled consistently and includes build metadata:
// Use the default version from the tools package
app := cli.New("app-name", "description")
// Or specify a custom version
app := cli.New(
"app-name",
"description",
cli.WithVersion("1.2.3"),
)
When users run your-app --version, they see:
app-name
Version: v1.2.3
Go version: go1.21.0
OS: linux
Arch: amd64
Git time: 2024-01-15 10:30:25 -0500 EST
Git dirty: false
Git revision: a1b2c3d4
Version information is automatically extracted from:
- VCS information (git commit, time, dirty status)
- Build information (Go version, OS, architecture)
- Custom version string (if provided)
Stream Configuration
For testing or custom I/O handling, you can configure stdin, stdout, and stderr:
// Configure individual streams
app := cli.New(
"app-name",
"description",
cli.WithStdin(customReader),
cli.WithStdout(customWriter),
cli.WithStderr(customWriter),
)
// Or configure all at once
app := cli.New(
"app-name",
"description",
cli.WithStreams(stdin, stdout, stderr),
)
Access streams in your run function:
func run(app *cli.App) error {
// Read from configured stdin
data, err := io.ReadAll(app.Stdin())
// Write to configured stdout
fmt.Fprintf(app.Stdout(), "Output: %s\n", result)
// Write to configured stderr
fmt.Fprintf(app.Stderr(), "Error: %v\n", err)
return nil
}
This is particularly useful for testing:
func TestApp(t *testing.T) {
stdin := strings.NewReader("input data")
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
app := cli.New(
"test-app",
"description",
cli.WithRunFunc(run),
cli.WithStreams(stdin, stdout, stderr),
)
err := app.Run(context.Background(), []string{"test-app"})
// Assert on stdout.String(), stderr.String(), err
}
Error Handling
The package provides utilities for working with multiple errors:
import "github.com/hashicorp/go-multierror"
func run(app *cli.App) error {
merr := cli.BuildInlineFormatMultiError()
if err := operation1(); err != nil {
merr = multierror.Append(merr, err)
}
if err := operation2(); err != nil {
merr = multierror.Append(merr, err)
}
// Returns: "2 errors occurred: error1; error2"
return merr.ErrorOrNil()
}
The inline format is more suitable for CLI output than the default multi-line format.
API Reference
App Creation
// New creates a new CLI application
func New(name, description string, opts ...AppOption) *App
App Options
// Core functionality
func WithRunFunc(fn RunFunc) AppOption
func WithFlags(fn func(*flag.FlagSet)) AppOption
// Versioning
func WithVersion(version string) AppOption
// Logging
func WithLogConfig(config LogConfig) AppOption
// Completion
func WithCustomCompletion(fn CustomCompletionFunc) AppOption
// I/O streams
func WithStdin(sin io.Reader) AppOption
func WithStdout(sout io.Writer) AppOption
func WithStderr(serr io.Writer) AppOption
func WithStreams(sin io.Reader, sout, serr io.Writer) AppOption
// Validation
func WithRequiredArguments(i int) AppOption
App Methods
// Run executes the application
func (a *App) Run(ctx context.Context, args []string) error
// Access context and arguments
func (a *App) Context() context.Context
func (a *App) Args() []string
// Access configured streams
func (a *App) Stdin() io.Reader
func (a *App) Stdout() io.Writer
func (a *App) Stderr() io.Writer
// Get version information
func (a *App) GetVersion() (VersionInfo, error)
Logging Configuration
// Create a new logging configuration
func NewLogConfig(opts ...LogOption) LogConfig
// Logging options
func WithLogLevel(level slog.Level) LogOption
func WithLogOutput(output io.Writer) LogOption
func WithLogFormat(format string) LogOption // "text" or "json"
Completion Types
type ShellType string
const (
Bash ShellType = "bash"
Zsh ShellType = "zsh"
Fish ShellType = "fish"
)
// CustomCompletionFunc generates custom completion logic for a shell
type CustomCompletionFunc func(shell ShellType) (string, error)
Error Utilities
// BuildInlineFormatMultiError creates a multierror with inline formatting
func BuildInlineFormatMultiError() *multierror.Error
Examples
Basic Application
package main
import (
"context"
"fmt"
"os"
flag "github.com/spf13/pflag"
"git.sr.ht/~jcmuller/tools/pkg/cli"
)
var configFile string
func main() {
app := cli.New(
"myapp",
"My application description\n\nUsage:\n %s [options]\n\nOptions:\n",
cli.WithRunFunc(run),
cli.WithFlags(buildFlags),
)
if err := app.Run(context.Background(), os.Args); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func buildFlags(fs *flag.FlagSet) {
fs.StringVarP(&configFile, "config", "c", "", "Config file path")
}
func run(app *cli.App) error {
ctx := app.Context()
slog.InfoContext(ctx, "Starting application", "config", configFile)
// Your application logic here
return nil
}
Application with Custom Completion
package main
import (
"context"
"fmt"
"os"
flag "github.com/spf13/pflag"
"git.sr.ht/~jcmuller/tools/pkg/cli"
)
func main() {
app := cli.New(
"myapp",
"My application\n\nUsage:\n %s [command] [options]\n\nOptions:\n",
cli.WithRunFunc(run),
cli.WithFlags(buildFlags),
cli.WithCustomCompletion(customCompletion),
)
if err := app.Run(context.Background(), os.Args); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func customCompletion(shell cli.ShellType) (string, error) {
switch shell {
case cli.Zsh:
return ` '*::arg:->args' && return 0
case $state in
args)
local -a subcommands
subcommands=(
'start:Start the service'
'stop:Stop the service'
'status:Check service status'
)
_describe 'subcommands' subcommands
;;
esac
`, nil
case cli.Bash:
return `
# Complete subcommands
local subcommands="start stop status"
COMPREPLY=( $(compgen -W "$subcommands" -- ${cur}) )
return 0`, nil
case cli.Fish:
return `
complete -c myapp -n "__fish_use_subcommand" -a "start" -d "Start the service"
complete -c myapp -n "__fish_use_subcommand" -a "stop" -d "Stop the service"
complete -c myapp -n "__fish_use_subcommand" -a "status" -d "Check service status"
`, nil
default:
return "", nil
}
}
func run(app *cli.App) error {
args := app.Args()
if len(args) == 0 {
return fmt.Errorf("no command provided")
}
switch args[0] {
case "start":
// Start logic
case "stop":
// Stop logic
case "status":
// Status logic
default:
return fmt.Errorf("unknown command: %s", args[0])
}
return nil
}
Application with JSON Logging
package main
import (
"context"
"log/slog"
"os"
"git.sr.ht/~jcmuller/tools/pkg/cli"
)
func main() {
logConfig := cli.NewLogConfig(
cli.WithLogFormat("json"),
cli.WithLogLevel(slog.LevelDebug),
)
app := cli.New(
"myapp",
"My application\n\nUsage:\n %s [options]\n\nOptions:\n",
cli.WithRunFunc(run),
cli.WithLogConfig(logConfig),
)
if err := app.Run(context.Background(), os.Args); err != nil {
os.Exit(1)
}
}
func run(app *cli.App) error {
ctx := app.Context()
slog.InfoContext(ctx, "operation started", "user", "alice", "operation", "backup")
slog.DebugContext(ctx, "processing item", "id", 123, "type", "file")
return nil
}
Best Practices
-
Keep the run function focused: The run function should only contain the core logic of your application. Extract complex logic into separate functions.
-
Use structured logging: Use slog.InfoContext, slog.DebugContext, etc. with key-value pairs for better log analysis:
slog.InfoContext(ctx, "message", "key1", value1, "key2", value2)
-
Handle errors properly: Return errors from your run function rather than calling os.Exit directly. This makes testing easier and provides better error messages.
-
Use the builder pattern: Use the builder pattern for configuration to make your code more readable and maintainable.
-
Provide custom completion for dynamic values: If your app has subcommands or accepts specific values, implement custom completion to improve user experience.
-
Test with custom streams: Use WithStreams to inject test doubles for stdin/stdout/stderr in your tests.
-
Use context: Always use the context from app.Context() and pass it to functions that accept context. This enables proper cancellation and timeout handling.
-
Document your flags: Provide clear descriptions for all flags. These descriptions appear in --help output and completion descriptions.
-
Use flag shortcuts wisely: Only add single-letter shortcuts (-f) for commonly used flags to avoid cluttering the shortcut namespace.
-
Handle required arguments: Use WithRequiredArguments(n) to validate minimum argument count rather than checking manually in your run function.
License
This package is part of the jcmuller/tools monorepo.