Skip to content

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.mod requires; 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:

curl -sSL "https://gitlab.com/phpboyscout/go-tool-base/-/raw/main/install.sh" | bash

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:

gtb version
Version: v0.35.0
Build:   c688fc4bd0fea96921011c08eb2c833b6ee99537
Date:    2026-08-02T13:13:34Z

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

cd mytool

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

just build          # or: go build -o bin/mytool ./cmd/mytool
./bin/mytool --help

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:

./bin/mytool doctor
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:

./bin/mytool init
INFO Initialising configuration
INFO configuration initialised path=/home/you/.mytool/config.yaml

Now the commands run:

./bin/mytool doctor
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:

gtb generate command --name hello --short "Say hello"

That writes two files:

  • pkg/cmd/hello/cmd.go — generated, DO NOT EDIT. The options struct, the flag wiring, and the NewCmdHello(props *props.Props) *setup.Command constructor.
  • pkg/cmd/hello/main.go — yours. A RunHello function, 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:

just build
./bin/mytool hello
INFO hello from mytool

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?

gtb regenerate project

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 boilerplate cmd.go is 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/ignore tells 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.