Build your first CLI¶
By the end of this you'll have a real, buildable CLI: configuration, structured logging, a consistent error path, self-update, embedded docs and a release pipeline — none of it written by you — plus one command that is yours.
Allow about twenty minutes. Most of that is Go downloading dependencies the first time; the scaffolding itself takes seconds.
Before you start¶
- Go 1.26.5 or newer. That is what
go.modrequires; an older toolchain will refuse to build. - A terminal you can install to. The install script drops a binary in
~/.local/bin. - Nothing else. You do not need a GitHub or GitLab account for this tutorial — the generator writes a release pipeline for one, but never contacts it.
Install the gtb CLI¶
GTB ships an automation CLI called gtb. Install it with the script:
That fetches a pre-built release — embedded docs and all — and puts gtb in
~/.local/bin, so make sure that directory is on your $PATH. Check it landed:
Everything below is verified against v0.35.0. GTB is pre-1.0 and moving, and the install script always pulls the latest release, so if something does not line up, a newer version is the likeliest reason — check the migration notes.
go install also works, but the installation guide
explains why the release binary is the better choice: a source build omits the
gitignored assets the embedded documentation browser needs.
Scaffold a project¶
One command stands up a whole project. Run it with flags when you want a repeatable setup:
gtb generate project \
--name mytool \
--repo myorg/mytool \
--description "My CLI tool" \
--env-prefix MYTOOL \
--path ./mytool
Set --env-prefix now. It is the prefix for the environment variables that can
override your configuration later — MYTOOL_LOG_LEVEL rather than a bare
LOG_LEVEL that would collide with every other tool on the machine. A tool with
no prefix has no environment layer at all.
Leave the flags off and gtb generate project walks you through an interactive
wizard instead, which is gentler the first time. gtb generate cli and
gtb generate skeleton are aliases for the same command.
The generator git-initialises the new project and makes an initial commit. Pass
--no-git to skip that.
One choice is worth knowing about before you see the result: features. GTB
bundles ready-made commands — self-update, embedded docs, a doctor health check,
an MCP server, a changelog, OS-keychain storage — and you pick which ones your
tool ships with at generation time, through --features or the wizard's
checklist. That is why your brand-new tool answers --help with commands you
never wrote. The full list is in the
generate reference.
Look at what you got¶
It is a complete, releasable project rather than a hello-world:
mytool/
├── cmd/mytool/main.go # entry point (generated)
├── pkg/cmd/root/
│ ├── cmd.go # builds Props, wires the root command (generated)
│ └── assets/init/config.yaml # the config template `init` writes
├── internal/version/version.go # version info, stamped at release
├── .gtb/manifest.yaml # the generator's record of your command tree
├── .github/workflows/ # lint, test, docs, release pipelines
├── docs/ # a Diátaxis-structured docs site
├── justfile # build / test / lint / docs tasks
├── go.mod
└── ... # .golangci.yaml, .goreleaser.yaml, README, CHANGELOG
.gtb/manifest.yaml is the one to understand first. It is the generator's source
of truth: every command your tool has, how they nest, and a content hash of each
generated file. You will not edit it by hand, but gtb reads and rewrites it
constantly, and it is what makes regeneration safe. It is committed to git for
you. See The manifest for how it is used.
The entry point is tiny, because the framework does the lifting. Here is the
generated cmd/mytool/main.go in full:
// Code generated by gtb. DO NOT EDIT.
package main
import (
version "github.com/myorg/mytool/internal/version"
root "github.com/myorg/mytool/pkg/cmd/root"
gtbRoot "gitlab.com/phpboyscout/go-tool-base/pkg/cmd/root"
)
func main() {
rootCmd, p := root.NewCmdRoot(version.Get())
gtbRoot.Execute(rootCmd, p)
}
Two lines of body. root.NewCmdRoot — in your own pkg/cmd/root/cmd.go —
builds the Props container that carries the logger, config, filesystem and
version to every command. gtbRoot.Execute runs the tree under a signal-aware
context and routes any failure through one error handler, so there is no
os.Exit scattered about.
Note the DO NOT EDIT header. main.go and the root cmd.go belong to the
generator. Your code goes elsewhere, which matters shortly.
Build it and run it¶
The first build downloads a lot of dependencies; give it a couple of minutes.
My CLI tool
Usage:
mytool [command]
Available Commands:
changelog Show version history
completion Generate the autocompletion script for the specified shell
docs Browse documentation
doctor Check environment and configuration health
help Help about any command
init Initialise configuration and bootstrap subsystems
mcp MCP server management
update Update to the latest available version
version Print version, commit, and build date
Flags:
--ci flag to indicate the tools is running in a CI environment
--config stringArray config files to use (default [/etc/mytool/config.yaml,/home/you/.mytool/config.yaml])
--debug forces debug log output
-h, --help help for mytool
--output string output format (text, json) (default "text")
Those are the features you picked, plus the global flags every GTB tool carries. None of it is code you wrote.
Give it a configuration¶
Try one of those commands and it stops:
ERRO failed to load configuration: no config file found hints="Run 'mytool init' to create a configuration."
That is deliberate. The tool will not guess at a configuration it does not have. Give it one:
Now the commands run:
mytool vnone
[OK] Go version: go1.26.5
[OK] Configuration: loaded successfully
[OK] Credential storage: no literal credentials in config
[OK] Permissions: config dir: /home/you/.mytool (-rwxr-xr-x)
init is itself a feature. A tool that should run straight from its built-in
defaults with no file at all can switch it off, or relax the check per command —
see Auto-initialise config. Leave it on
for now.
The file it wrote is ~/.mytool/config.yaml — note the leading dot on the
directory; it is not ~/.config/. The
configuration reference covers every key and the
order the layers resolve in.
Add your first command¶
Do not hand-roll a command file. The generator writes the boilerplate and leaves you the logic:
That writes two files:
pkg/cmd/hello/cmd.go— generated,DO NOT EDIT. The options struct, the flag wiring, and theNewCmdHello(props *props.Props) *setup.Commandconstructor.pkg/cmd/hello/main.go— yours. ARunHellofunction, where the real logic goes.
It also registers the command in the root tree, updates .gtb/manifest.yaml,
and writes a reference page at docs/reference/cli/hello.md.
The split between the two files is the whole point. Open
pkg/cmd/hello/main.go; it starts as a stub returning
errorhandling.ErrNotImplemented. Replace it with what the command should do:
package hello
import (
"context"
"gitlab.com/phpboyscout/go-tool-base/pkg/props"
)
func RunHello(_ context.Context, props *props.Props, _ *HelloOptions, _ []string) error {
props.Logger.Info("hello from mytool")
return nil
}
Rebuild, and it is wired in:
You never touched the root command to register it. If you would rather wire commands by hand against the library, Custom commands shows that route; this tutorial follows the generated one.
Regenerate without losing your work¶
This is the part people are right to be wary of. If the generator owns cmd.go
and the root wiring, what happens when it runs again — which it does on every
gtb generate command?
Open pkg/cmd/hello/main.go again: your RunHello is untouched. Three separate
things protect it.
- Your logic sits in a file the generator never rewrites. Command logic lives
in
main.go; only the boilerplatecmd.gois regenerated. The split is the contract, not a convention. - It notices if you edited a generated file. The manifest stores a content hash of every generated file, so a changed one stops regeneration and asks rather than silently stamping over you.
- You can fence files off entirely. A gitignore-style
.gtb/ignoretells the generator to leave specific paths alone, even under--force. See Configure generator ignore.
--force is the exception, and it is exactly as destructive as it sounds: it
overwrites main.go implementation files, resetting your command logic to the
starter stub. There is no undo beyond git, which is one reason the generator
commits for you.
Where this leaves you¶
A few minutes in, you have a CLI with configuration, logging, a consistent error path, self-update, embedded docs and a release pipeline, plus your own command and the confidence to regenerate as the tool grows.
What to read next¶
- Configuration keys — every key, its default, and what happens when it is wrong.
- Bind flags to config — let a flag override a configuration value.
- Generating commands — nested subcommands, flags, and the eight-field flag spec.
- Built-in features — turning the optional commands on and off.
- Architecture fundamentals — what
Propsis and why every command receives one.