Project Awesome project awesome

Standard CLI > boa

Declarative flags, env vars, validation, and config files from struct tags. Built on cobra.

Package 42 stars GitHub

BOA

CI Status Go Report Card Docs

Like if kong and urfave/cli had a baby and made it cobra compatible.

Self-documenting CLIs from Go structs. Define your parameters once and get flags, env vars, validation, config file loading, and help text — all generated automatically. The result is a CLI that's easy to write, easy for humans to use, and easy for LLMs to invoke — because the full parameter schema is right there in --help.

Built on top of cobra, not replacing it. Full cobra interop when you need it.

Full Documentation

Quick Start

go get github.com/GiGurra/boa@latest
type Params struct {
    Name string `descr:"your name"`
    Port int    `descr:"port number" default:"8080" optional:"true"`
}

func main() {
    boa.CmdT[Params]{
        Use:   "my-app",
        Short: "a simple CLI tool",
        RunFunc: func(params *Params, cmd *cobra.Command, args []string) {
            fmt.Printf("Hello %s on port %d\n", params.Name, params.Port)
        },
    }.Run()
}

This is what you get — flag names, short flags, defaults, required/optional, descriptions, and usage line all generated from the struct:

$ my-app --help
a simple CLI tool

Usage:
  my-app [flags]

Flags:
  -h, --help          help for my-app
  -n, --name string   your name (required)
  -p, --port int      port number (default 8080)

And this is how you interact with it:

$ my-app --name Alice
Hello Alice on port 8080

$ my-app --name Bob --port 3000
Hello Bob on port 3000

$ my-app
Usage:
  my-app [flags]

Flags:
  -h, --help          help for my-app
  -n, --name string   your name (required)
  -p, --port int      port number (default 8080)

Error: required flag "name" not set
Parameter Types

All standard Go types work out of the box:

type Params struct {
    Host    string            `descr:"server host"`                    // required by default
    Port    int               `descr:"port" default:"8080"`            // with default
    Name    *string           `descr:"user name"`                      // pointer = optional, nil = not set
    Tags    []string          `descr:"tags" default:"[a,b,c]"`         // --tags a,b,c
    Labels  map[string]string `descr:"labels"`                         // --labels env=prod,team=backend
    Input   string            `positional:"true"`                      // positional arg
    Timeout time.Duration     `descr:"timeout" default:"30s"`          // durations, IPs, URLs, etc.
    Matrix  [][]int           `descr:"matrix" optional:"true"`         // complex types use JSON: '[[1,2],[3,4]]'
}

Pointer fields are optional by default — nil means "not set", so you can distinguish between "user passed zero" and "user didn't pass anything":

type Params struct {
    Retries *int `descr:"retry count"` // nil if not provided, *0 if --retries 0
}

boa.CmdT[Params]{
    Use: "app",
    RunFunc: func(p *Params, cmd *cobra.Command, args []string) {
        if p.Retries != nil {
            fmt.Printf("Retrying %d times\n", *p.Retries)
        } else {
            fmt.Println("Using default retry strategy")
        }
    },
}.Run()
Subcommands
type ServeParams struct {
    Host string `descr:"bind address" default:"0.0.0.0"`
    Port int    `descr:"port" default:"8080"`
}

type DeployParams struct {
    Target string `descr:"deploy target" alts:"staging,production" strict:"true"`
    DryRun bool   `descr:"dry run mode" optional:"true"`
}

func main() {
    boa.CmdT[boa.NoParams]{
        Use:   "my-app",
        Short: "a multi-command CLI",
        SubCmds: boa.SubCmds(
            boa.CmdT[ServeParams]{
                Use: "serve", Short: "start the server",
                RunFunc: func(p *ServeParams, cmd *cobra.Command, args []string) {
                    fmt.Printf("Serving on %s:%d\n", p.Host, p.Port)
                },
            },
            boa.CmdT[DeployParams]{
                Use: "deploy", Short: "deploy the app",
                RunFunc: func(p *DeployParams, cmd *cobra.Command, args []string) {
                    fmt.Printf("Deploying to %s (dry-run: %v)\n", p.Target, p.DryRun)
                },
            },
        ),
    }.Run()
}
$ my-app --help
a multi-command CLI

Usage:
  my-app [command]

Available Commands:
  serve       start the server
  deploy      deploy the app

$ my-app deploy --target staging --dry-run
Deploying to staging (dry-run: true)

$ my-app bogus
Error: unknown command "bogus" for "my-app"
Config Files

Tag a field with configfile and boa loads it automatically. CLI and env vars always win:

type Params struct {
    ConfigFile string     `configfile:"true" optional:"true" default:"config.json"`
    Host       string     `descr:"server host"`
    Port       int        `descr:"port" default:"8080"`
    Internal   [][]string `boa:"configonly"` // loaded from config only, no CLI flag
}
$ cat config.json
{"Host": "prod.example.com", "Port": 443, "Internal": [["a","b"],["c","d"]]}

$ my-app                              # uses config.json values
$ my-app --host override.local        # CLI wins over config file
$ my-app --config-file staging.json   # different config file
$ HOST=ci.local my-app                # env var wins over config file

Nested structs can have their own config files. Priority: CLI > env > root config > substruct config > defaults:

type DBConfig struct {
    ConfigFile string `configfile:"true" optional:"true"`
    Host       string `default:"localhost"`
    Port       int    `default:"5432"`
}

type Params struct {
    ConfigFile string   `configfile:"true" optional:"true" default:"app.json"`
    DB         DBConfig
}

JSON is built in. Register other formats with one line:

boa.RegisterConfigFormat(".yaml", yaml.Unmarshal)
boa.RegisterConfigFormat(".toml", toml.Unmarshal)
Live Config Reload

For long-running programs, boa.Reload[T](https://github.com/GiGurra/boa/blob/HEAD/ctx) re-reads every config file, re-applies precedence (CLI still wins), re-validates, and returns a brand-new *T. The struct you originally got in RunFunc is never touched — every reload is a fresh allocation, and callers swap the pointer (typically via atomic.Pointer[T]) only when they're ready. On any failure — parse error, validation failure, missing file — Reload returns (nil, err) and nothing else changes, so noisy triggers (fsnotify fires multiple events per save) are safe to wire directly.

import (
    "os"
    "os/signal"
    "sync/atomic"
    "syscall"
)

var active atomic.Pointer[Params]

boa.CmdT[Params]{
    Use: "server",
    RunFuncCtx: func(ctx *boa.HookContext, p *Params, cmd *cobra.Command, args []string) {
        active.Store(p)

        sighup := make(chan os.Signal, 1)
        signal.Notify(sighup, syscall.SIGHUP)
        go func() {
            for range sighup {
                fresh, err := boa.Reload[Params](https://github.com/GiGurra/boa/blob/HEAD/ctx)
                if err != nil {
                    log.Printf("reload rejected: %v", err) // old config still serving
                    continue
                }
                active.Store(fresh)
            }
        }()

        startServer()
    },
}.Run()

Readers elsewhere in the program just do cfg := active.Load() — lock-free, always-consistent snapshots. ctx.WatchedConfigFiles() returns the paths you should hand to your trigger (fsnotify, timer, etc.).

No fsnotify in core. Wire your own trigger: SIGHUP, an admin HTTP endpoint, fsnotify, a timer. The primitive just answers "give me a fresh validated config now". A higher-level watcher subpackage is planned as a follow-up.

See the Live Config Reload page for SIGHUP / HTTP / fsnotify / timer recipes, error semantics, and the atomic-pointer swap pattern.

Struct Composition

Named fields auto-prefix their children. Embedded fields stay flat:

type DBConfig struct {
    Host string `default:"localhost"`
    Port int    `default:"5432"`
}

type CommonFlags struct {
    Verbose bool `optional:"true"`
}

type Params struct {
    CommonFlags           // embedded: --verbose (no prefix)
    Primary DBConfig      // named: --primary-host, --primary-port
    Replica DBConfig      // named: --replica-host, --replica-port
}
$ my-app --help
Flags:
  --verbose              (default false)
  --primary-host string  (default "localhost")
  --primary-port int     (default 5432)
  --replica-host string  (default "localhost")
  --replica-port int     (default 5432)

Deep nesting chains prefixes: Infra.Primary.Host becomes --infra-primary-host. Env vars follow the same pattern: INFRA_PRIMARY_HOST.

Validation

Struct tag validation:

type Params struct {
    Port     int    `descr:"port" min:"1" max:"65535"`
    LogLevel string `descr:"log level" alts:"debug,info,warn,error" strict:"true"`
    Name     string `descr:"name" pattern:"^[a-z]+$"`
    Tags     []string `descr:"tags" min:"1" max:"5"` // min/max = slice length
}

Programmatic validation with HookContext — for cases where struct tags aren't enough:

type Params struct {
    Host string `descr:"Server hostname"`
    Port int    `descr:"Server port"`
    CIDR string `descr:"Allowed CIDR range" optional:"true"`
}

boa.CmdT[Params]{
    Use: "server",
    InitFuncCtx: func(ctx *boa.HookContext, p *Params, cmd *cobra.Command) error {
        // Type-safe custom validator
        boa.GetParamT(ctx, &p.Port).SetCustomValidatorT(func(port int) error {
            if port < 1024 && port != 80 && port != 443 {
                return fmt.Errorf("non-standard privileged port %d", port)
            }
            return nil
        })

        // Conditional required: CIDR only required when host is not localhost
        ctx.GetParam(&p.CIDR).SetRequiredFn(func() bool {
            return p.Host != "localhost"
        })

        return nil
    },
    RunFunc: func(p *Params, cmd *cobra.Command, args []string) {
        fmt.Printf("Listening on %s:%d\n", p.Host, p.Port)
    },
}.Run()

You can also implement validation directly on your params struct:

type ServerConfig struct {
    Host     string
    Port     int
    LogLevel string
}

func (c *ServerConfig) InitCtx(ctx *boa.HookContext) error {
    ctx.GetParam(&c.Port).SetDefault(boa.Default(8080))
    ctx.GetParam(&c.LogLevel).SetAlternatives([]string{"debug", "info", "warn", "error"})
    ctx.GetParam(&c.LogLevel).SetStrictAlts(true)
    return nil
}
Error Handling
Method Behavior
Run() Shows usage + error on bad input, exits 1. Other errors panic.
RunE() Returns all errors silently for programmatic use
ToCobra() Returns *cobra.Command for custom execution via boa.Execute(cmd)

Run() for simple CLIs:

boa.CmdT[Params]{
    Use: "app",
    RunFunc: func(p *Params, cmd *cobra.Command, args []string) {
        fmt.Println("Success!")
    },
}.Run()
// Bad input → prints usage + error, exits 1
// Runtime panic → crashes with stack trace

RunE() when you need error handling:

err := boa.CmdT[Params]{
    Use: "app",
    RunFuncE: func(p *Params, cmd *cobra.Command, args []string) error {
        if p.Port < 1024 {
            return fmt.Errorf("port must be >= 1024")
        }
        return nil
    },
}.RunE()

if err != nil {
    log.Fatalf("Command failed: %v", err)
}

ToCobra() when embedding boa in a larger cobra app:

cmd := boa.CmdT[Params]{
    Use: "sub",
    RunFunc: func(p *Params, cmd *cobra.Command, args []string) { ... },
}.ToCobra()

rootCmd.AddCommand(cmd)
boa.Execute(rootCmd) // prints usage + error on failure
Shell Completions

Every boa CLI gets shell completions for free via cobra. No extra code needed:

$ my-app completion bash   # bash completions
$ my-app completion zsh    # zsh completions
$ my-app completion fish   # fish completions
$ my-app completion powershell  # powershell completions

Install once and get tab completion for all flags, subcommands, and enum values:

# bash
my-app completion bash > /etc/bash_completion.d/my-app

# zsh
my-app completion zsh > "${fpath[1]}/_my-app"

# fish
my-app completion fish > ~/.config/fish/completions/my-app.fish

Fields with alts automatically complete to their allowed values.

Static completions from struct tags:

type Params struct {
    LogLevel string `descr:"log level" alts:"debug,info,warn,error"` // tab-completes to these values
}

Dynamic completions — e.g. completing based on output from other CLIs:

boa.CmdT[Params]{
    Use: "deploy",
    InitFunc: func(p *Params, cmd *cobra.Command) error {
        cmd.RegisterFlagCompletionFunc("namespace", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
            // Call kubectl to get real namespace list
            out, err := exec.Command("kubectl", "get", "namespaces", "-o", "jsonpath={.items[*].metadata.name}").Output()
            if err != nil {
                return nil, cobra.ShellCompDirectiveError
            }
            return strings.Fields(string(out)), cobra.ShellCompDirectiveNoFileComp
        })
        return nil
    },
    RunFunc: func(p *Params, cmd *cobra.Command, args []string) { ... },
}.Run()
// $ deploy --namespace <TAB>
// default    kube-system    production    staging
Struct Tags Reference
Tag Description Example
descr / desc Description text descr:"User name"
name / long Override flag name name:"user-name"
default Default value default:"8080"
env Environment variable name env:"PORT"
short Short flag (single char) short:"p"
positional / pos Marks positional argument positional:"true"
required / req Marks as required required:"true"
optional / opt Marks as optional optional:"true"
alts Allowed values (enum) alts:"debug,info,warn,error"
strict Validate against alts strict:"true"
min Min value or min length min:"1"
max Max value or max length max:"65535"
pattern Regex pattern pattern:"^[a-z]+$"
configfile Auto-load config from path configfile:"true"
boa Special directives boa:"ignore" (no mirror), boa:"configonly" (no CLI/env, mirror + validation preserved), boa:"noflag" / "nocli", boa:"noenv"

Further Reading

  • Getting Started — all parameter types, subcommands, config files
  • Struct Tags — complete tag reference with auto-prefixing
  • Bring Someone Else's Config — wire up third-party / tag-less structs programmatically
  • Validation — required/optional, alternatives, conditional requirements
  • Lifecycle Hooks — customize behavior at each stage
  • Enrichers — auto-derive flag names, env vars, short flags
  • Error Handling — Run() vs RunE() and error propagation
  • Advanced — custom types, config format registry, viper-like discovery
  • Live Config Reload — re-read config without restart via boa.Reload[T](https://github.com/GiGurra/boa/blob/HEAD/ctx)
  • Cobra Interop — access cobra primitives, migrate incrementally
Back to Go