cli

package
v0.23.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jan 20, 2026 License: MIT Imports: 13 Imported by: 1

README

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

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

  1. Keep the run function focused: The run function should only contain the core logic of your application. Extract complex logic into separate functions.

  2. 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)
    
  3. Handle errors properly: Return errors from your run function rather than calling os.Exit directly. This makes testing easier and provides better error messages.

  4. Use the builder pattern: Use the builder pattern for configuration to make your code more readable and maintainable.

  5. Provide custom completion for dynamic values: If your app has subcommands or accepts specific values, implement custom completion to improve user experience.

  6. Test with custom streams: Use WithStreams to inject test doubles for stdin/stdout/stderr in your tests.

  7. Use context: Always use the context from app.Context() and pass it to functions that accept context. This enables proper cancellation and timeout handling.

  8. Document your flags: Provide clear descriptions for all flags. These descriptions appear in --help output and completion descriptions.

  9. Use flag shortcuts wisely: Only add single-letter shortcuts (-f) for commonly used flags to avoid cluttering the shortcut namespace.

  10. 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.

Documentation

Index

Constants

This section is empty.

Variables

View Source
var SetupLogging = func(config LogConfig) {
	var handler slog.Handler

	if config.Format == "json" {
		handler = slog.NewJSONHandler(config.Output, &slog.HandlerOptions{
			Level: config.Level,
		})
	} else {
		handler = slog.NewTextHandler(config.Output, &slog.HandlerOptions{
			Level: config.Level,
		})
	}

	logger := slog.New(handler)
	slog.SetDefault(logger)
}

SetupLogging configures the global logger based on the provided config It's a variable to allow for testing

Functions

func BuildInlineFormatMultiError added in v0.14.0

func BuildInlineFormatMultiError() *multierror.Error

Types

type App

type App struct {

	// Debug enables debug logging
	Debug bool

	// ExitFunc is used for testing
	ExitFunc func(int)
	// contains filtered or unexported fields
}

App represents a command-line application with common functionality

func New

func New(name, description string, opts ...AppOption) *App

New creates a new CLI application with common flags and behavior

func (*App) Args added in v0.15.0

func (a *App) Args() []string

func (*App) Context added in v0.15.0

func (a *App) Context() context.Context

func (*App) GenerateCompletion added in v0.11.0

func (a *App) GenerateCompletion(config CompletionConfig) error

GenerateCompletion generates shell completion for the app

func (*App) GetVersion added in v0.13.0

func (a *App) GetVersion() (VersionInfo, error)

func (*App) Run

func (a *App) Run(ctx context.Context, args []string) error

Run executes the application

func (*App) Stderr added in v0.14.0

func (a *App) Stderr() io.Writer

func (*App) Stdin added in v0.14.0

func (a *App) Stdin() io.Reader

func (*App) Stdout added in v0.14.0

func (a *App) Stdout() io.Writer

type AppOption

type AppOption func(*App)

AppOption is a function that configures an App

func WithCustomCompletion added in v0.22.0

func WithCustomCompletion(fn CustomCompletionFunc) AppOption

WithCustomCompletion sets a custom completion function that will be called to generate app-specific completion logic for each shell type

func WithFlags

func WithFlags(fn func(*flag.FlagSet)) AppOption

WithFlags adds custom flags to the application

func WithLogConfig

func WithLogConfig(config LogConfig) AppOption

WithLogConfig sets a custom logging configuration

func WithRequiredArguments added in v0.17.0

func WithRequiredArguments(i int) AppOption

WithRequiredArguments sets the minimum number of required arguments

func WithRunFunc

func WithRunFunc(fn RunFunc) AppOption

WithRunFunc sets the main function to execute

func WithStderr added in v0.14.0

func WithStderr(serr io.Writer) AppOption

WithStderr configures stderr for the application

func WithStdin added in v0.14.0

func WithStdin(sin io.Reader) AppOption

WithStdin configures stdin for the application

func WithStdout added in v0.14.0

func WithStdout(sout io.Writer) AppOption

WithStdout configures stdout for the application

func WithStreams added in v0.14.0

func WithStreams(sin io.Reader, sout, serr io.Writer) AppOption

WithStreams configures all streams

func WithVersion

func WithVersion(version string) AppOption

WithVersion sets a custom version for the application

type CompletionConfig added in v0.11.0

type CompletionConfig struct {
	// Shell is the type of shell to generate completion for
	Shell ShellType
	// Output is where to write the completion script
	Output io.Writer
}

CompletionConfig holds configuration for shell completion

func DefaultCompletionConfig added in v0.11.0

func DefaultCompletionConfig() CompletionConfig

DefaultCompletionConfig returns the default completion configuration

func NewCompletionConfig added in v0.11.0

func NewCompletionConfig(opts ...CompletionOption) CompletionConfig

NewCompletionConfig creates a new completion configuration with options

type CompletionOption added in v0.11.0

type CompletionOption func(*CompletionConfig)

CompletionOption configures the completion generator

func WithCompletionOutput added in v0.11.0

func WithCompletionOutput(output io.Writer) CompletionOption

WithCompletionOutput sets the output for the completion script

func WithCompletionShell added in v0.11.0

func WithCompletionShell(shell ShellType) CompletionOption

WithCompletionShell sets the shell type for completion

type CustomCompletionFunc added in v0.22.0

type CustomCompletionFunc func(shell ShellType) (string, error)

CustomCompletionFunc is a function that generates custom completion logic for a specific shell

type LogConfig

type LogConfig struct {
	// Level is the minimum log level to output
	Level slog.Level

	// Output is where logs will be written (defaults to stderr)
	Output io.Writer

	// Format determines the log format (text or json)
	Format string
}

LogConfig holds configuration for setting up logging

func DefaultLogConfig

func DefaultLogConfig() LogConfig

DefaultLogConfig returns a default logging configuration

func NewLogConfig

func NewLogConfig(opts ...LogOption) LogConfig

NewLogConfig creates a new logging configuration with options

type LogOption

type LogOption func(*LogConfig)

LogOption is a function that configures a LogConfig

func WithLogFormat

func WithLogFormat(format string) LogOption

WithLogFormat sets the log format (text or json)

func WithLogLevel

func WithLogLevel(level slog.Level) LogOption

WithLogLevel sets the minimum log level

func WithLogOutput

func WithLogOutput(output io.Writer) LogOption

WithLogOutput sets where logs will be written

type RunFunc added in v0.14.0

type RunFunc func(app *App) error

type ShellType added in v0.11.0

type ShellType string

ShellType represents a supported shell for completion

const (
	// Bash shell
	Bash ShellType = "bash"
	// Zsh shell
	Zsh ShellType = "zsh"
	// Fish shell
	Fish ShellType = "fish"
)

type VersionInfo

type VersionInfo struct {
	Arch        string
	BuildTime   string
	Commit      string
	Dirty       string
	GoVersion   string
	Name        string
	OS          string
	Version     string
	GitTime     time.Time
	MainVersion string
}

VersionInfo holds version information for the application

func (VersionInfo) String

func (v VersionInfo) String() (string, error)

String returns a formatted version string

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL