Skip to content

Advanced Features

This page covers advanced BOA features for power users.

The Param Interface

Every parameter (whether using struct tags or programmatic configuration) implements the Param interface. Access it via HookContext.GetParam():

boa.CmdT[Params]{
    Use: "cmd",
    InitFuncCtx: func(ctx *boa.HookContext, p *Params, cmd *cobra.Command) error {
        param := ctx.GetParam(&p.SomeField)
        // Now use param methods...
        return nil
    },
}

Param Methods

Method Description
SetName(string) Override flag name
SetShort(string) Set short flag
SetEnv(string) Set environment variable
SetDefault(any) Set default value
SetAlternatives([]string) Set allowed values
SetAlternativesFunc(func(...) []string) Set dynamic completion function
SetStrictAlts(bool) Enable/disable strict validation
SetRequiredFn(func() bool) Dynamic required condition
SetIsEnabledFn(func() bool) Dynamic visibility
GetName() string Get current flag name
GetShort() string Get current short flag
GetEnv() string Get current env var
GetAlternatives() []string Get allowed values
GetAlternativesFunc() Get dynamic completion function
HasValue() bool Check if value was set
IsRequired() bool Check if required
IsEnabled() bool Check if visible

Typed Parameter API (ParamT)

For type-safe parameter configuration, use boa.GetParamT[T]() instead of GetParam(). This returns a ParamT[T] interface with typed methods:

type Params struct {
    Port int    `descr:"Server port"`
    Host string `descr:"Server host"`
}

boa.CmdT[Params]{
    Use: "server",
    InitFuncCtx: func(ctx *boa.HookContext, p *Params, cmd *cobra.Command) error {
        // Type-safe: compiler ensures correct types
        portParam := boa.GetParamT(ctx, &p.Port)
        portParam.SetDefaultT(8080)  // Takes int, not any
        portParam.SetCustomValidatorT(func(port int) error {
            if port < 1 || port > 65535 {
                return fmt.Errorf("port must be between 1 and 65535")
            }
            return nil
        })

        hostParam := boa.GetParamT(ctx, &p.Host)
        hostParam.SetDefaultT("localhost")  // Takes string
        hostParam.SetAlternatives([]string{"localhost", "0.0.0.0"})

        return nil
    },
}

ParamT Methods

The ParamT[T] interface provides typed methods plus all pass-through methods from Param:

Typed Methods Description
SetDefaultT(T) Set default value with compile-time type checking
SetCustomValidatorT(func(T) error) Set validation function that receives the typed value
Pass-through Methods Description
Param() Access the underlying untyped Param interface
SetAlternatives([]string) Set allowed values
SetStrictAlts(bool) Enable/disable strict validation
SetAlternativesFunc(...) Set dynamic completion function
SetEnv(string) Set environment variable
SetShort(string) Set short flag
SetName(string) Set flag name
SetIsEnabledFn(func() bool) Dynamic visibility
SetRequiredFn(func() bool) Dynamic required condition

Conditional Requirements with ParamT

type DeployParams struct {
    Environment string `descr:"Target environment" default:"dev"`
    ProdKey     string `descr:"Production API key" optional:"true"`
}

boa.CmdT[DeployParams]{
    Use: "deploy",
    InitFuncCtx: func(ctx *boa.HookContext, p *DeployParams, cmd *cobra.Command) error {
        // ProdKey is only required when deploying to production
        prodKeyParam := boa.GetParamT(ctx, &p.ProdKey)
        prodKeyParam.SetRequiredFn(func() bool {
            return p.Environment == "prod"
        })
        return nil
    },
}

Dynamic Shell Completion

AlternativesFunc

For completion suggestions that depend on runtime state (like fetching from an API), use SetAlternativesFunc via HookContext:

type Params struct {
    Region string `descr:"AWS region"`
}

func main() {
    boa.CmdT[Params]{
        Use: "app",
        InitFuncCtx: func(ctx *boa.HookContext, p *Params, cmd *cobra.Command) error {
            ctx.GetParam(&p.Region).SetAlternativesFunc(
                func(cmd *cobra.Command, args []string, toComplete string) []string {
                    // Could fetch from API, read from file, etc.
                    return []string{"us-east-1", "us-west-2", "eu-west-1"}
                },
            )
            return nil
        },
        RunFunc: func(params *Params, cmd *cobra.Command, args []string) {
            // ...
        },
    }.Run()
}

ValidArgsFunc

For positional argument completion:

boa.CmdT[Params]{
    Use: "app",
    ValidArgsFunc: func(p *Params, cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
        // Return suggestions for positional args
        return []string{"option1", "option2"}, cobra.ShellCompDirectiveDefault
    },
}

Config File Loading

Using the configfile Tag

Tag a string field with configfile:"true" to automatically load a config file before validation:

type Params struct {
    ConfigFile string `configfile:"true" optional:"true" default:"config.json"`
    Host       string
    Port       int
}

boa.CmdT[Params]{
    Use: "app",
    RunFunc: func(p *Params, cmd *cobra.Command, args []string) {
        fmt.Printf("Host: %s, Port: %d\n", p.Host, p.Port)
    },
}.Run()

CLI and env var values always take precedence over config file values.

Config Format Registry

JSON is the only format shipped by default. Register additional formats by file extension:

import "gopkg.in/yaml.v3"

boa.RegisterConfigFormat(".yaml", yaml.Unmarshal)
boa.RegisterConfigFormat(".toml", toml.Unmarshal)

Resolution order for unmarshal function:

  1. Explicit ConfigUnmarshal on the command
  2. Registered format matched by file extension
  3. json.Unmarshal (default fallback)

You can also set ConfigUnmarshal directly on a command to override all format detection for that command:

boa.CmdT[Params]{
    Use:             "app",
    ConfigUnmarshal: yaml.Unmarshal,
    RunFunc:         func(p *Params, cmd *cobra.Command, args []string) { ... },
}.Run()

Substruct Config Files

The configfile:"true" tag works on fields inside nested structs, not just at root. Each substruct can have its own config file:

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

type CacheConfig struct {
    ConfigFile string `configfile:"true" optional:"true"`
    TTL        int    `descr:"cache TTL" default:"300"`
}

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

Substruct configs load first, then root config loads and overrides any overlapping values. The full merge priority is:

  1. CLI flags -- highest
  2. Environment variables
  3. Root config file
  4. Substruct config files
  5. Default values
  6. Zero value -- lowest

This lets you split configuration across multiple files while maintaining a clear override hierarchy.

Using LoadConfigFile Explicitly

For more control (e.g., loading into a sub-struct, multiple config files), use LoadConfigFile in a PreValidate hook:

type AppConfig struct {
    Host string
    Port int
}

type Params struct {
    ConfigFile string `descr:"Path to config file" optional:"true"`
    AppConfig
}

boa.CmdT[Params]{
    Use: "app",
    PreValidateFunc: func(p *Params, cmd *cobra.Command, args []string) error {
        return boa.LoadConfigFile(p.ConfigFile, &p.AppConfig, nil)
    },
    RunFunc: func(p *Params, cmd *cobra.Command, args []string) {
        fmt.Printf("Host: %s, Port: %d\n", p.Host, p.Port)
    },
}.Run()

Checking Value Sources

Use HookContext in your run function to check how values were set:

boa.CmdT[Params]{
    Use: "app",
    RunFuncCtx: func(ctx *boa.HookContext, p *Params, cmd *cobra.Command, args []string) {
        if ctx.HasValue(&p.Port) {
            fmt.Printf("Port explicitly set to %d\n", p.Port)
        } else {
            fmt.Println("Using default port")
        }
    },
}

Accessing Cobra Directly

Access the underlying Cobra command for features BOA doesn't wrap:

boa.CmdT[Params]{
    Use: "app",
    InitFunc: func(p *Params, cmd *cobra.Command) error {
        cmd.Deprecated = "use 'new-app' instead"
        cmd.Hidden = true
        cmd.SilenceUsage = true
        return nil
    },
}

Or after flags are created:

boa.CmdT[Params]{
    Use: "app",
    PostCreateFuncCtx: func(ctx *boa.HookContext, p *Params, cmd *cobra.Command) error {
        flag := cmd.Flags().Lookup("verbose")
        flag.NoOptDefVal = "true"  // --verbose without value means true
        return nil
    },
}

Command Groups

Organize subcommands into groups in help output:

boa.CmdT[boa.NoParams]{
    Use: "app",
    Groups: []*cobra.Group{
        {ID: "core", Title: "Core Commands:"},
        {ID: "util", Title: "Utility Commands:"},
    },
    SubCmds: boa.SubCmds(
        boa.CmdT[Params]{Use: "init", GroupID: "core"},
        boa.CmdT[Params]{Use: "run", GroupID: "core"},
        boa.CmdT[Params]{Use: "version", GroupID: "util"},
    ),
}

Testing Commands

Inject Arguments

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

// Test with specific args
cmd.RunArgs([]string{"--name", "test", "--port", "8080"})

Testing with Error Returns

Use RunFuncE and RunArgsE for testable commands that return errors:

func TestMyCommand(t *testing.T) {
    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
        },
    }.RunArgsE([]string{"--port", "80"})

    if err == nil {
        t.Fatal("expected error for port < 1024")
    }
}

Use ToCobraE() when you need the underlying cobra command with RunE set:

func TestMyCommand(t *testing.T) {
    cmd, err := boa.CmdT[Params]{
        Use: "app",
        RunFuncE: func(p *Params, cmd *cobra.Command, args []string) error {
            return nil
        },
    }.ToCobraE()
    if err != nil {
        t.Fatalf("setup failed: %v", err)
    }

    cmd.SetArgs([]string{"--name", "test"})
    err = cmd.Execute()
    // Assert on err
}

Validate Without Running

cmd := boa.CmdT[Params]{
    Use:     "app",
    RawArgs: []string{"--name", "test"},
}

err := cmd.Validate()
if err != nil {
    // Validation failed
}

Interface-Based Hooks

Implement interfaces on your config struct instead of using function fields:

type Config struct {
    Host string
    Port int
}

// Called during initialization
func (c *Config) Init() error {
    return nil
}

// Called during initialization with HookContext
func (c *Config) InitCtx(ctx *boa.HookContext) error {
    ctx.GetParam(&c.Port).SetDefault(boa.Default(8080))
    return nil
}

// Called before validation
func (c *Config) PreValidate() error {
    return nil
}

// Called before execution
func (c *Config) PreExecute() error {
    return nil
}

Available interfaces:

  • CfgStructInit - Init() error
  • CfgStructInitCtx - InitCtx(ctx *HookContext) error
  • CfgStructPostCreate - PostCreate() error
  • CfgStructPostCreateCtx - PostCreateCtx(ctx *HookContext) error
  • CfgStructPreValidate - PreValidate() error
  • CfgStructPreValidateCtx - PreValidateCtx(ctx *HookContext) error
  • CfgStructPreExecute - PreExecute() error
  • CfgStructPreExecuteCtx - PreExecuteCtx(ctx *HookContext) error

JSON Fallback for Complex Types

Any field type that doesn't have native pflag support (e.g., nested slices, complex maps) automatically falls back to JSON parsing. BOA registers the flag as a StringP and uses json.Unmarshal to parse the value.

This means you can use arbitrarily complex types in your params struct:

type Params struct {
    Matrix  [][]int              `descr:"nested matrix" optional:"true"`
    Meta    map[string][]string  `descr:"metadata" optional:"true"`
    Config  map[string]any       `descr:"arbitrary config" optional:"true"`
}

// CLI usage:
//   --matrix '[[1,2],[3,4]]'
//   --meta '{"tags":["a","b"],"owners":["alice"]}'
//   --config '{"debug":true,"retries":3}'

The same JSON syntax works for environment variables. Config files work natively since they are already unmarshaled from JSON/YAML/etc.

Simple Maps

Simple map types like map[string]string and map[string]int use the more ergonomic key=val,key=val syntax on the CLI, and only fall back to JSON for complex value types:

type Params struct {
    Labels map[string]string   `descr:"simple key=val syntax"`
    Deep   map[string][]string `descr:"JSON syntax required"`
}
// --labels env=prod,team=backend
// --deep '{"groups":["admin","users"]}'

Config-File-Only Fields with boa:"ignore" / boa:"configonly"

Fields tagged with boa:"ignore" (or its alias boa:"configonly") are excluded from CLI flag and environment variable registration. They won't appear in --help and can't be set via the command line.

However, config file loading uses json.Unmarshal (or your configured unmarshal function) which writes directly to struct fields, bypassing the CLI layer entirely. This means ignored fields are still populated from config files.

This pattern is useful for fields that only make sense in a config file:

type Params struct {
    ConfigFile string            `configfile:"true" optional:"true" default:"config.json"`
    Host       string            `descr:"server host"`
    Port       int               `descr:"server port"`
    InternalID string            `boa:"configonly"` // only from config file
    Metadata   map[string]string `boa:"configonly"` // complex config, not a CLI concern
}

boa:"configonly" is functionally identical to boa:"ignore" but communicates intent more clearly.

With config.json:

{
    "Host": "example.com",
    "Port": 8080,
    "InternalID": "abc-123",
    "Metadata": {"version": "2", "region": "us-east-1"}
}

Host and Port can be overridden via CLI flags; InternalID and Metadata are only loaded from the config file.

Named Struct Auto-Prefixing

Named (non-anonymous) struct fields automatically prefix their children's flag names and env var names with the field name in kebab-case. This is the primary mechanism for avoiding flag name collisions when reusing struct types.

How It Works

type DBConfig struct {
    Host string `descr:"database host" default:"localhost"`
    Port int    `descr:"database port" default:"5432"`
}

type Params struct {
    Primary DBConfig  // --primary-host, --primary-port, env: PRIMARY_HOST, PRIMARY_PORT
    Replica DBConfig  // --replica-host, --replica-port, env: REPLICA_HOST, REPLICA_PORT
}

Prefixing Rules

Scenario Flag Name Env Var
Embedded DBConfig with Host --host HOST
Named DB DBConfig with Host --db-host DB_HOST
Deep Infra.Primary.Host --infra-primary-host INFRA_PRIMARY_HOST
Named field + explicit name:"host" --db-host (prefixed) N/A
Named field + explicit env:"SERVER_HOST" N/A DB_SERVER_HOST (prefixed)
Embedded + explicit env:"MY_HOST" N/A MY_HOST (not prefixed)

Deep Nesting

Prefixes chain at every named (non-anonymous) level:

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

type ClusterConfig struct {
    Primary ConnectionConfig
    Replica ConnectionConfig
}

type Params struct {
    Infra ClusterConfig
}
// Flags: --infra-primary-host, --infra-primary-port, --infra-replica-host, --infra-replica-port
// Env vars: INFRA_PRIMARY_HOST, INFRA_PRIMARY_PORT, etc.

Explicit Tags Are Also Prefixed

Inside a named struct field, both auto-generated and explicit tag values get the parent prefix. This is intentional -- it avoids collisions when the same struct type appears multiple times:

type ServerConfig struct {
    Host string `name:"host" env:"SERVER_HOST" default:"localhost"`
}

type Params struct {
    API ServerConfig  // flag: --api-host, env: API_SERVER_HOST
    Web ServerConfig  // flag: --web-host, env: WEB_SERVER_HOST
}

Custom Type Registration

Register user-defined types as CLI parameters with RegisterType. The type is stored as a string flag in cobra and converted via your provided Parse/Format functions:

type SemVer struct {
    Major, Minor, Patch int
}

func init() {
    boa.RegisterType[SemVer](boa.TypeDef[SemVer]{
        Parse: func(s string) (SemVer, error) {
            var v SemVer
            _, err := fmt.Sscanf(s, "%d.%d.%d", &v.Major, &v.Minor, &v.Patch)
            return v, err
        },
        Format: func(v SemVer) string {
            return fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch)
        },
    })
}

type Params struct {
    Version SemVer `descr:"app version" default:"1.0.0"`
}

TypeDef[T] fields:

Field Type Description
Parse func(string) (T, error) Converts a CLI string into the typed value (required)
Format func(T) string Converts the typed value back to a string for default display. If nil, fmt.Sprintf("%v", val) is used

ConfigFormatExtensions

boa.ConfigFormatExtensions() returns the file extensions that have registered config format handlers. Always includes .json (registered by default). This is used by the boaviper subpackage for auto-discovery:

exts := boa.ConfigFormatExtensions() // e.g., [".json", ".yaml"]

Type Handler Registry (Architecture)

Internally, BOA uses a type handler registry (type_handler.go) instead of scattered type switches. Each handler provides:

  • bindFlag -- how to create a cobra/pflag flag for this type
  • parse -- how to convert a string value into the target type
  • convert -- optional post-parse conversion (e.g., for types stored as strings in cobra)

Handlers are registered by exact type (for special types like time.Time, net.IP) or by reflect.Kind (for basic types like string, int). Map types use composed handlers that delegate value parsing to the appropriate scalar handler for their value type.

Types without a registered handler fall back to StringP + json.Unmarshal, which is how nested slices and complex maps are supported automatically.