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://gtb.phpboyscout.uk/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.50.0
Build:   9ec6a11ec3329991a55cf963c2eebf1d4aa1a728
Date:    2026-11-01T02:09:04Z

Everything below is verified against v0.50.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.

Do not go install gtb. The installation guide explains why: it builds the CLI against an older framework release and can fail to compile. If you want a source build, use a checkout and just install.

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)
β”‚   └── forge.go, keychain.go, mcp.go  # one file per linked feature (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 (
    "fmt"
    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"
    errorhandling "gitlab.com/phpboyscout/go/errorhandling"
    "os"
)

func main() {
    rootCmd, p, err := root.NewCmdRoot(version.Get())
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(errorhandling.ExitCodeUsage)
    }
    gtbRoot.Execute(rootCmd, p)
}

(The generated file also carries a doc comment above main, left out here.)

The error branch catches a wiring defect in your root, a feature enabled that no import declares, say, and exits 2 before any command runs. 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         Manage MCP servers
  update      Update to the latest available version
  version     Print version, commit, and build date

Flags:
      --accessible           run wizards as line prompts rather than a full-screen form (also GTB_ACCESSIBLE=true)
      --ci                   the tool is running in a CI environment: no prompts, no update check, no telemetry consent
      --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

Ask the tool how it's doing:

./bin/mytool doctor
mytool vdev

  [OK] Go version: go1.27.3
  [SKIP] Configuration: no config file yet
       Running on embedded defaults; `mytool init` creates one.
  [OK] Environment variables: every MYTOOL_* variable sets a known key
  [OK] Config stack: 5 layers, no config sources
  ...

Your Go version will differ, and the list runs on past what's shown here. The line to look at is Configuration. The tool runs on the defaults compiled into it, and there's no file of yours yet. Write one:

./bin/mytool init --skip-login --skip-key
INFO  Initialising configuration
INFO  configuration initialised path=/home/you/.mytool/config.yaml

Without those two flags, init also walks you through logging in to GitHub and setting up an SSH key, because the scaffold links the GitHub integration. You don't need either for this tutorial, and mytool init github runs that setup on its own whenever you do.

Run doctor again and the Configuration line reads [OK], with the path of the file it loaded.

init is itself a feature, and a tool that never needs a file of its own can switch it off. 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.