The framework supports loading flag values from external configuration files via ConfigResolver. Config values have lower priority than CLI args and environment variables, making them ideal for defaults.
Values are resolved in this order (highest to lowest):
- CLI argument —
--port 8080 - Environment variable —
PORT=8080 - Config resolver — from config file
- Default tag —
default:"8080" - Zero value —
0for int,""for string
A ConfigResolver is a simple function:
type ConfigResolver func(key ConfigKey) (value string, found bool)Given a flag name, it returns the string value and whether it was found. The framework handles all type conversion, validation, and required checks.
The ConfigKey provides both the flag name and decomposed parts:
type ConfigKey struct {
Name string // full flag name (e.g., "db-host")
Parts []string // decomposed parts (e.g., ["db", "host"])
}Use Parts for resolvers backed by nested formats like YAML or JSON.
Set a resolver for all commands:
f, _ := os.Open("config.json")
resolver, _ := config.FromJSON(f)
cli.Execute(ctx, root, args,
cli.WithConfigResolver(resolver),
)Implement ConfigProvider for command-specific resolvers:
type ServeCmd struct {
ConfigPath string `flag:"config" help:"Config file path"`
Port int `flag:"port" help:"Port to listen on"`
}
func (s *ServeCmd) ConfigResolver() cli.ConfigResolver {
if s.ConfigPath == "" {
return nil
}
f, err := os.Open(s.ConfigPath)
if err != nil {
return nil
}
resolver, _ := config.FromJSON(f)
return resolver
}The resolver is called after CLI args are parsed, so --config can specify the config file path dynamically.
The config subpackage provides these resolvers:
Create a resolver from a string map:
import "github.com/bjaus/cli/config"
resolver := config.FromMap(map[string]string{
"port": "8080",
"host": "localhost",
})Load a flat JSON object:
f, _ := os.Open("config.json")
resolver, err := config.FromJSON(f)Config file (config.json):
{
"port": "8080",
"host": "localhost",
"verbose": "true"
}Parse a .env file:
f, _ := os.Open(".env")
resolver, err := config.FromEnvFile(f)Config file (.env):
PORT=8080
HOST=localhost
# Comments are supported
VERBOSE=true
export API_KEY="secret" # export prefix worksSupported syntax:
KEY=VALUEpairs- Quoted values:
KEY="VALUE"orKEY='VALUE' - Comments: lines starting with
#and inline comments exportprefix:export KEY=VALUE
Try multiple resolvers in priority order:
resolver := config.Chain(
localOverrides, // highest priority
projectConfig, // project-level
globalConfig, // user defaults
)The first resolver that finds a value wins.
Since FromMap accepts any map[string]string, adding new formats is straightforward.
import "gopkg.in/yaml.v3"
func FromYAML(r io.Reader) (cli.ConfigResolver, error) {
var m map[string]string
if err := yaml.NewDecoder(r).Decode(&m); err != nil {
return nil, err
}
return config.FromMap(m), nil
}import "github.com/BurntSushi/toml"
func FromTOML(r io.Reader) (cli.ConfigResolver, error) {
var m map[string]string
if _, err := toml.NewDecoder(r).Decode(&m); err != nil {
return nil, err
}
return config.FromMap(m), nil
}import "github.com/hashicorp/hcl/v2"
func FromHCL(r io.Reader) (cli.ConfigResolver, error) {
data, err := io.ReadAll(r)
if err != nil {
return nil, err
}
var m map[string]string
if err := hcl.Unmarshal(data, &m); err != nil {
return nil, err
}
return config.FromMap(m), nil
}For sources that don't map to files, implement ConfigResolver directly:
func FromConsul(client *consul.Client, prefix string) cli.ConfigResolver {
return func(key cli.ConfigKey) (string, bool) {
pair, _, err := client.KV().Get(prefix+"/"+key.Name, nil)
if err != nil || pair == nil {
return "", false
}
return string(pair.Value), true
}
}func FromSSM(client *ssm.Client, prefix string) cli.ConfigResolver {
return func(key cli.ConfigKey) (string, bool) {
path := prefix + "/" + key.Name
param, err := client.GetParameter(ctx, &ssm.GetParameterInput{
Name: &path,
WithDecryption: aws.Bool(true),
})
if err != nil {
return "", false
}
return *param.Parameter.Value, true
}
}func FromVault(client *vault.Client, path string) cli.ConfigResolver {
return func(key cli.ConfigKey) (string, bool) {
secret, err := client.Logical().Read(path)
if err != nil || secret == nil {
return "", false
}
if val, ok := secret.Data[key.Name].(string); ok {
return val, true
}
return "", false
}
}For nested config formats, use ConfigKey.Parts:
// config.yaml:
// database:
// host: localhost
// port: 5432
type Cmd struct {
DB DBConfig `prefix:"db-"`
}
type DBConfig struct {
Host string `flag:"host"`
Port int `flag:"port"`
}The flag --db-host has:
Name:"db-host"Parts:["db", "host"]
Use Parts to navigate nested structures:
func FromNestedYAML(data map[string]any) cli.ConfigResolver {
return func(key cli.ConfigKey) (string, bool) {
current := data
for i, part := range key.Parts {
if i == len(key.Parts)-1 {
// Last part: get the value
if val, ok := current[part].(string); ok {
return val, true
}
return "", false
}
// Navigate deeper
if nested, ok := current[part].(map[string]any); ok {
current = nested
} else {
return "", false
}
}
return "", false
}
}type App struct {
ConfigPath string `flag:"config" short:"c" help:"Config file path"`
Verbose bool `flag:"verbose" short:"v" help:"Verbose output"`
}
func (a *App) ConfigResolver() cli.ConfigResolver {
if a.ConfigPath == "" {
return nil
}
f, err := os.Open(a.ConfigPath)
if err != nil {
return nil
}
defer f.Close()
// Detect format from extension
switch filepath.Ext(a.ConfigPath) {
case ".json":
resolver, _ := config.FromJSON(f)
return resolver
case ".yaml", ".yml":
resolver, _ := FromYAML(f)
return resolver
case ".env":
resolver, _ := config.FromEnvFile(f)
return resolver
default:
return nil
}
}
type ServeCmd struct {
Port int `flag:"port" default:"8080" help:"Port to listen on"`
Host string `flag:"host" default:"localhost" help:"Host to bind to"`
}
func main() {
cli.ExecuteAndExit(ctx, &App{}, os.Args)
}Usage:
# Use CLI args (highest priority)
$ app serve --port 9000
# Use environment variable
$ PORT=3000 app serve
# Use config file
$ app --config config.json serve
# Layer all three (CLI wins over env wins over config)
$ PORT=3000 app --config config.json serve --port 9000
# Port = 9000 (CLI wins)Build a configuration hierarchy:
func (a *App) ConfigResolver() cli.ConfigResolver {
resolvers := []cli.ConfigResolver{}
// Project config (highest priority)
if f, err := os.Open(".app.yaml"); err == nil {
if r, err := FromYAML(f); err == nil {
resolvers = append(resolvers, r)
}
f.Close()
}
// User config
home, _ := os.UserHomeDir()
if f, err := os.Open(filepath.Join(home, ".config/app/config.yaml")); err == nil {
if r, err := FromYAML(f); err == nil {
resolvers = append(resolvers, r)
}
f.Close()
}
// System defaults (lowest priority)
if f, err := os.Open("/etc/app/defaults.yaml"); err == nil {
if r, err := FromYAML(f); err == nil {
resolvers = append(resolvers, r)
}
f.Close()
}
if len(resolvers) == 0 {
return nil
}
return config.Chain(resolvers...)
}