Featured image of post Building a CLI with go-tool-base, part 2: configuration your tool can trust

Building a CLI with go-tool-base, part 2: configuration your tool can trust

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:

  1. Command-line flags
  2. Environment variables (your tool’s prefix plus the key, so hello.greeting reads MYTOOL_HELLO_GREETING, with the dots turned into underscores)
  3. 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 init command (coming up) bakes them into your config file for you.
  • The file init writes, ~/.mytool/config.yaml, is the default the tool reads, along with a machine-wide /etc/mytool/config.yaml if one exists.
  • Files passed with --config replace 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? init is 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 keeps init on, 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.

Built with Hugo
Theme Stack designed by Jimmy