In part 1
you scaffolded a tool and gave it a hello command. It says the same thing
every time, which is fine for a first command and useless for a real one. The
moment a tool does anything worth doing it needs settings: an endpoint, a
default, a token, a log level. And the moment you have settings, you have the
problem nobody warns you about. You set one in a file, the tool ignores it, the
code that reads it looks perfectly correct, and an hour later you find you’d
typed tiemout. Nothing in the whole stack thought that worth a word.
The good news is you don’t have to build any of this. Your scaffold already wired up a config system in part 1, the same one the rest of go-tool-base uses. This part puts it to work: where a setting’s value actually comes from, how to ship sensible defaults alongside the command they belong to, how to layer files so a team and a laptop can disagree politely, and how to turn a fat-fingered key from a silent shrug into an error that tells you exactly what you got wrong.
The same version note as part 1, since each of these stands on its own:
everything here is written against go-tool-base v0.6.0 (gtb version will
tell you what you’re on). The tool is young and still changing shape, so if you’re
on a newer release and a detail has drifted, that’s the first thing to check. I’ll
flag anything that breaks across versions as it comes up.
You already have a config system
The root command loads configuration for you before any of your command code
runs, merges every source together, and hands the result to each command through
Props.
By the time your RunHello runs, props.Config is populated and ready.
A value can arrive from several places at once, so there’s an order. Highest wins:
- Command-line flags
- Environment variables (your tool’s prefix plus the key, so
hello.greetingreadsMYTOOL_HELLO_GREETING, with the dots turned into underscores) - Config files (on disk, in the order they were loaded)
That ladder is the mental model for what beats what: a flag beats an env var, an env var beats a file. The files are worth pinning down, though, because there’s more than one and they don’t all come from the same place. This is the bit that’s easy to trip over:
- Embedded defaults are baked into the binary, one slice per command. You
don’t read these at runtime directly. The
initcommand (coming up) bakes them into your config file for you. - The file
initwrites,~/.mytool/config.yaml, is the default the tool reads, along with a machine-wide/etc/mytool/config.yamlif one exists. - Files passed with
--configreplace those defaults for that run rather than adding to them. Name one or more and the tool reads exactly those.
We’ll set each of these up in turn. The full reference lives in the config docs.
Reading a value is one call, and it’s typed:
greeting := props.Config.GetString("hello.greeting")
timeout := props.Config.GetDuration("server.timeout")
debug := props.Config.GetBool("verbose")
Give a command a setting
Let’s make hello configurable. Open pkg/cmd/hello/main.go (your file, the one
the generator leaves alone) and read the greeting from config instead of
hard-coding it:
func RunHello(ctx context.Context, props *props.Props, opts *HelloOptions, args []string) error {
greeting := props.Config.GetString("hello.greeting")
props.Logger.Info(greeting)
return nil
}
Build and run it:
just build
./bin/mytool hello
ERRO failed to load config: no configuration files found
please run init, or provide a config file using the --config flag
Not what you expected, maybe, but it’s the right instinct from the tool. It has no configuration to read yet, and rather than guess, it stops and says so. Which brings us neatly to where settings actually come from.
Defaults belong to the command
You could drop a default into the project’s central config, and for something
truly global like the log level that’s the right home. But a setting that belongs
to hello should live with hello, not in a shared file you have to remember to
edit every time you add a command. The generator does this for you, you just have
to ask. Back in part 1 you generated hello without config support, so run the
same command again with --assets:
gtb generate command --name hello --short "Say hello" --assets
This is safe to re-run. The generator honours the code you’ve already written:
it refreshes the boilerplate cmd.go, adds the asset scaffolding, and leaves
your main.go, and the RunHello you’ve been editing, completely alone. One
thing to hold off on here: don’t reach for --force. Force rewrites everything,
including that main.go, which is exactly the work you want to keep.
You now have pkg/cmd/hello/assets/init/config.yaml, and the generator has
already opened it under the command’s own namespace:
hello:
Fill in your defaults under it:
hello:
greeting: Hello
style: plain
Those values are embedded into the binary as an asset, and the generated cmd.go
registers them with Props for you (props.Assets.Register("hello", &assets)),
so the config system knows where your command’s defaults live. A quick word on
style, since we’ll lean on it shortly: it’s a second setting I’m giving a
default now so it’s ready when we need it. Plain says the greeting as written;
loud will shout it.
That per-command home comes with one rule worth taking seriously: namespace your
keys. Notice the generator opened the file under a hello: key rather than at
the top level. Copy that. Every command ships its defaults in its own embedded
file, and those files are all merged together to build the config, but the order
they merge in is not guaranteed. If two commands both defined a top-level
timeout, which one won would be a toss-up that could flip between builds. Keep
each command’s settings under its own name (hello.greeting, report.timeout)
and the clash can’t happen in the first place. The generator namespacing the file
for you is a hint worth taking.
One thing the defaults file does not do is set values through struct tags. If you
later add a default:"info" tag to a config field, that’s documentation for the
error messages, nothing more. Real defaults live here, in the embedded YAML. It’s
an easy thing to assume otherwise and then wonder why your default never applied.
First run: init
So your defaults are baked into the binary. The tool still needs an actual config
file to read, and that’s what init is for. It’s one of the features your tool
shipped with, so it’s already there:
./bin/mytool init
INFO Configuration initialised in /home/you/.mytool/config.yaml
Open that file and you’ll find your command’s defaults waiting in it, merged with the framework’s own:
hello:
greeting: Hello
style: plain
log:
level: info
That’s the missing piece. init gathers every command’s embedded defaults
through the Assets layer, writes them to ~/.mytool/config.yaml, locks the
file down to 0600 (it may hold secrets later), and drops in a .gitignore so
nobody commits it by accident. Now hello has something to read:
Prefer no init step?
initis a feature, and you can leave it out of your tool’s feature set. With it off, the tool loads its embedded defaults directly and runs with no config file at all, you’d only add one to override something. That suits a small, self-contained tool. This tutorial keepsiniton, which is the default and the right call while a tool is finding its feet, so the rest of the article assumes it.
./bin/mytool hello
INFO Hello
Setup that needs a human: initialisers
Static defaults cover the values you can decide for the user. Some you can’t: a token, an API key, an endpoint that differs per person. Writing a blank or guessed value for those is worse than useless. This is where go-tool-base does something I’ve not seen many CLI frameworks bother with: it lets a command bring its own first-run setup, and wires it in for you. It’s one of the genuine reasons to build on the framework rather than roll your own, so it’s worth a proper look.
Generate a command with --with-initializer:
gtb generate command --name greet --short "Greet someone" --with-initializer
Alongside the usual files you get an init.go. It’s generated and marked DO NOT EDIT, and it does all the wiring. Here’s the heart of it:
// Code generated by gtb. DO NOT EDIT.
package greet
func init() {
setup.Register(props.FeatureCmd("greet"),
[]setup.InitialiserProvider{func(p *props.Props) setup.Initialiser {
if skipGreet {
return nil
}
return &GreetInitialiser{}
}},
[]setup.SubcommandProvider{func(p *props.Props) []*cobra.Command {
return []*cobra.Command{NewCmdInitGreet(p)}
}},
[]setup.FeatureFlag{func(cmd *cobra.Command) {
cmd.Flags().BoolVar(&skipGreet, "skip-greet", false, "skip initializing greet configuration")
}},
)
}
type GreetInitialiser struct{}
func (i *GreetInitialiser) Name() string { return "greet" }
func (i *GreetInitialiser) IsConfigured(cfg config.Containable) bool {
return cfg.IsSet("greet")
}
func (i *GreetInitialiser) Configure(p *props.Props, cfg config.Containable) error {
return InitGreet(p, cfg)
}
That package init() registers three things with the framework the moment your
command is imported, with no central setup file for you to edit: the initialiser
itself, an init greet subcommand so the user can reconfigure just this command
later, and a --skip-greet flag on the main init. IsConfigured is how the
framework avoids nagging: if the greet key is already in the config, init
leaves it be and moves on.
All of that is generated for you. The one piece that’s yours is the InitGreet
function in main.go, which starts as a stub:
func InitGreet(p *props.Props, cfg config.Containable) error {
// TODO: Implement custom initialization logic for greet
return nil
}
Fill it in with whatever the setup needs. go-tool-base leans on huh for prompts, the same library its own GitHub and AI setup use, so a one-question form looks like this:
func InitGreet(p *props.Props, cfg config.Containable) error {
var greeting string
form := huh.NewForm(
huh.NewGroup(
huh.NewInput().
Title("What greeting should greet use?").
Value(&greeting),
),
)
if err := form.Run(); err != nil {
return err
}
cfg.Set("greet.greeting", greeting)
return nil
}
Set the value on cfg and you’re done. After the initialisers run, init writes
the whole config out to disk, so the answer persists into ~/.mytool/config.yaml
with everything else. Run mytool init on a fresh machine now and it stops to ask
for the greeting; run it again and it sails past, because IsConfigured sees the
key is already there. Need to redo just this one command’s setup? mytool init greet. The framework hands each command its own setup step, its own subcommand
and its own skip flag, and asks you for a single function in return. That’s the
trade worth making: static defaults in your embedded YAML, anything that needs a
human in an initialiser.
Overriding: the environment and layered files
With a config file in place, the other sources come into their own. The quickest
override is an environment variable. Remember the prefix you set when scaffolding
in part 1: hello.greeting maps to MYTOOL_HELLO_GREETING, the prefix and key
joined up, uppercased, dots turned to underscores:
MYTOOL_HELLO_GREETING="Hello from mytool" ./bin/mytool hello
INFO Hello from mytool
You didn’t register that variable anywhere; the config system binds it for you.
The prefix is what keeps it from colliding with some other tool’s LOG_LEVEL on
the same machine, which is exactly why it’s worth having.
Files are the other half, and they’re where that precedence list earns a closer
look. A single config file is fine until two people, or two machines, want
slightly different settings, and then you’re copying files around by hand. The
--config flag fixes that: pass it more than once and the tool merges the files
in order.
./bin/mytool hello \
--config ./config.yaml \
--config ./config.local.yaml
Between the files you name, the rule is later wins on a clash, and every key
that doesn’t clash is kept. If config.yaml sets hello.greeting: Hello and
config.local.yaml sets hello.greeting: Oi, you get Oi, but keys that appear
in only one file survive untouched. It’s a merge between them, not a replacement.
The edge to remember is what --config does to the default locations: it replaces them.
The moment you name a file, ~/.mytool/config.yaml drops out of the picture
unless you name it too. So you pass the whole stack you want, a shared base and a
local override together, and let precedence settle it. Commit a config.yaml with
the team’s settings, keep an untracked config.local.yaml for your own, run with
both, and your local tweaks win without anyone editing a shared file. Leave
--config off and you’re back on the defaults init wrote: ~/.mytool/config.yaml
plus that machine-wide /etc/mytool/config.yaml if it’s there. Whichever set of
files you land on, environment variables and flags still sit on top.
The typo that does nothing
Now for the failure I keep circling. Say you want to change the greeting. Open your config, but fat-finger the key:
hello:
greting: Oi # meant to be greeting
Run it, and you get a blank line. The greeting you set never applied: the
misspelled key was read, matched nothing, and was silently dropped, and the real
greeting is now nowhere to be found. Nothing said a word. For a greeting it’s a
shrug. For a timeout or a retry count it’s the bug you chase at 2am, and I wrote
up the why of it in
the config key that quietly did nothing.
go-tool-base won’t catch this for you by default, and that’s a choice rather than an oversight. There’s no central schema that knows every key your tool could ever take, because keys belong to the commands that use them. What you get instead is a way to opt a command in, so it validates its own slice and nobody else’s.
Making mistakes loud
Tell the generator you want validation for a command and it scaffolds exactly
this (gtb generate command --name hello --with-config-validation). Since
hello already exists, it’s a small file to add by hand. Create
pkg/cmd/hello/config.go:
package hello
import "gitlab.com/phpboyscout/go-tool-base/pkg/config"
// HelloConfig describes the config keys the hello command consumes.
type HelloConfig struct {
Greeting string `config:"hello.greeting" validate:"required"`
Style string `config:"hello.style" enum:"plain,loud" default:"plain"`
}
// ValidateHelloConfig checks the hello config against its schema.
func ValidateHelloConfig(cfg config.Containable) error {
return config.ValidateStruct[HelloConfig](cfg)
}
The tags carry the rules. validate:"required" means the key has to be present
and non-empty. enum:"plain,loud" means style has to be one of those two words.
config.ValidateStruct[HelloConfig] does the rest: it derives a schema from those
tags and checks the config against it, returning a readable error if anything is
off. It takes props.Config as it is, the Containable interface, so there’s no
casting to a concrete type. Call it at the top of RunHello, before you trust any
of the values, and use the style while you’re there:
func RunHello(ctx context.Context, props *props.Props, opts *HelloOptions, args []string) error {
if err := ValidateHelloConfig(props.Config); err != nil {
return err
}
greeting := props.Config.GetString("hello.greeting")
if props.Config.GetString("hello.style") == "loud" {
greeting = strings.ToUpper(greeting)
}
props.Logger.Info(greeting)
return nil
}
(You’ll add strings to the imports at the top of main.go.)
Now make a real mistake. Set the style to something that isn’t allowed:
hello:
greeting: Hello
style: shout
ERRO config validation failed:
hello.style: value "shout" is not allowed (hint: allowed values: plain, loud)
That’s the difference. The command stops and tells you the key, the bad value,
and what it would have accepted. The same check catches a misspelled
greeting: the moment the real key goes missing, required fails with
hello.greeting: required field is missing instead of quietly running on
nothing. Set style: loud and you get HELLO, because the value finally passes
and the code downstream can trust it.
If you switch on the optional config feature (it isn’t in the default set, so
you opt into it), you also get a ready-made mytool config validate command that
runs these checks without you wiring anything into a command at all. Either way,
the principle holds: the program already knows what good config looks like, so
make it say so when the config is bad.
The upshot
Your hello command now reads a real setting, ships a sensible default that
init writes into place, honours overrides from the environment and from layered
files in a predictable order, and refuses to run on a value it doesn’t understand.
That’s most of what configuration ever needs to be, and you wrote almost none of
the machinery.
One thing I’ve skipped: config can also reload while the tool is running, so a long-lived process picks up a changed file without a restart. That’s its own capability with its own moving parts, and I pulled it apart in reloading config without a restart if you need it.
Next part, we give the tool something to do with all this config: we turn it into an AI tool, with a chat command and an MCP server. Until then, go add a couple of validated settings to your own commands. You’ve got the shape of it now.
