Skip to content

Generating a CLI Skeleton

The journey of a thousand miles begins with a single step, and for your new tool, that step is gtb generate project. πŸ› οΈ

Scaffolding a project from scratch can be tedious. generate project fast-tracks this process by setting up a robust, industry-standard project structure that's ready for high-scale development.

What's included in the box?

When you run generate project, we set up a complete, working CLI project:

Project Core
A main package in cmd/<name>/ and a root command in pkg/cmd/root.
Modern Tooling
A go.mod pinned to the Go version that generated it (--go-version), with tool directives for the framework's changelog and docs tooling so go tool runs them without a global install.
CI/CD Readiness
A pipeline for the forge the project is hosted on: GitHub Actions workflows for --forge-backend github, a .gitlab-ci.yml for gitlab. Gitea, Codeberg and Bitbucket have no CI skeleton, and the run says so.
Standard Layout
A pkg/ directory for your logic and a DiΓ‘taxis-structured docs/ directory for your users.
The Manifest
A .gtb/manifest.yaml file that acts as the brain of your project, tracking your settings and command hierarchy, beside a .gtb/ignore for files you take over.

Project Structure Summary

What gtb generate project --name mytool --repo acme/mytool --features init,update,doctor,docs writes:

mytool/
β”œβ”€β”€ .github/workflows/          # CI/CD for the GitHub backend (a .gitlab-ci.yml for GitLab)
β”œβ”€β”€ .gtb/
β”‚   β”œβ”€β”€ manifest.yaml           # The Brain: settings and command hierarchy
β”‚   └── ignore                  # Files regenerate must leave alone
β”œβ”€β”€ cmd/mytool/
β”‚   β”œβ”€β”€ main.go                 # Entry Point: builds the root and hands it to Execute
β”‚   β”œβ”€β”€ chat.go                 # Chat provider links; present only with the ai feature
β”‚   └── forge.go                # Forge adapter links; present only with a forge feature
β”œβ”€β”€ internal/version/version.go # Build-time version, stamped by goreleaser
β”œβ”€β”€ pkg/cmd/root/
β”‚   β”œβ”€β”€ cmd.go                  # The Root: Props construction and command registration
β”‚   β”œβ”€β”€ generate.go             # Generator marker; grows as commands are added
β”‚   └── assets/init/config.yaml # Seed config for the init feature: yours to edit
β”œβ”€β”€ docs/                       # Documentation: a DiΓ‘taxis-structured site
β”‚   β”œβ”€β”€ tutorials/              #   Learning-oriented (neutral; off-site/blog by default)
β”‚   β”œβ”€β”€ how-to/                 #   Task-oriented guides
β”‚   β”œβ”€β”€ reference/cli/          #   Generated CLI command reference
β”‚   └── explanation/components/ #   Generated package/architecture docs
β”œβ”€β”€ go.mod                      # Dependencies, plus tool directives for changelog and docs
β”œβ”€β”€ justfile                    # Build, test, lint, docs and release recipes
└── README.md                   # Onboarding: install, build, develop, and links into GTB docs

The keychain feature adds cmd/mytool/keychain.go, a blank import of the framework's keychain link; --signing adds internal/trustkeys/ and pkg/cmd/root/signing.go.

The Generated Root Command

pkg/cmd/root/cmd.go describes the tool, builds its Props through props.New (the one construction path) and returns the root command. It is a DO NOT EDIT file: the manifest owns it, and gtb enable, gtb disable, gtb set and gtb generate command rewrite it.

Annotated Example: pkg/cmd/root/cmd.go

As generated, with one command added by gtb generate command --name greet:

// Code generated by gtb. DO NOT EDIT.

package root

import (
    "embed"
    afero "github.com/spf13/afero"
    greet "github.com/acme/mytool/pkg/cmd/greet"
    gtbRoot "gitlab.com/phpboyscout/go-tool-base/pkg/cmd/root"
    logger "gitlab.com/phpboyscout/go-tool-base/pkg/logger"
    props "gitlab.com/phpboyscout/go-tool-base/pkg/props"
    setup "gitlab.com/phpboyscout/go-tool-base/pkg/setup"
    forge "gitlab.com/phpboyscout/go-tool-base/pkg/setup/forge"
    version "gitlab.com/phpboyscout/go-tool-base/pkg/version"
    "os"
)

//go:embed assets/*
var assets embed.FS

// NewCmdRoot builds the tool's Props through props.New, the one construction
// path, and the command tree on it. The error is a wiring defect in this
// file (an unnamed tool, a feature enabled that no import declares); main
// exits 2 on it.
func NewCmdRoot(v version.Info) (*setup.Command, *props.Props, error) {
    l := logger.NewCharm(os.Stderr, logger.WithTimestamp(true), logger.WithLevel(logger.InfoLevel))

    tool := props.Tool{
        Description: "Example tool",
        Features:    props.SetFeatures(props.Disable(props.McpCmd), props.Disable(props.ChangelogCmd), props.Enable(forge.GithubFeature)),
        Name:        "mytool",
        ReleaseSource: props.ReleaseSource{
            Host:  "github.com",
            Owner: "acme",
            Repo:  "mytool",
            Type:  "github",
        },
        Summary: "mytool utility",
    }

    p, err := props.New(tool, l, afero.NewOsFs(), props.WithAssets(props.NewAssets(props.AssetMap{"root": &assets})), props.WithVersion(v))
    if err != nil {
        return nil, nil, err
    }

    rootCmd := gtbRoot.NewCmdRoot(p,
        greet.NewCmdGreet(p))

    return rootCmd, p, nil
}

Three things to notice. Features lists only what differs from the framework defaults, so mcp and changelog appear as disabled and the GitHub forge as enabled; the six default built-ins are on without being named. props.New returns an error rather than panicking, and the root command is a *setup.Command, the framework's wrapper that carries the feature gate and middleware chain. Commands are registered as arguments to gtbRoot.NewCmdRoot, one per generated command. A help channel chosen with --help-type appears as Help: props.SlackHelp{...} on the Tool literal.

The Tool Entry Point

cmd/mytool/main.go builds the root and hands it to gtbRoot.Execute, which owns the exit code:

// Code generated by gtb. DO NOT EDIT.

package main

import (
    "fmt"
    version "github.com/acme/mytool/internal/version"
    root "github.com/acme/mytool/pkg/cmd/root"
    gtbRoot "gitlab.com/phpboyscout/go-tool-base/pkg/cmd/root"
    errorhandling "gitlab.com/phpboyscout/go/errorhandling"
    "os"
)

// main delegates to gtbRoot.Execute, which runs the command tree with a
// signal-aware context: SIGINT/SIGTERM cancel cmd.Context() for graceful
// shutdown, a second signal force-exits immediately, and a signal-terminated
// run exits 128+signum (130 SIGINT, 143 SIGTERM).
// A construction error (an unnamed tool, an undeclared feature enabled) is a
// defect in this project's wiring and exits 2, the usage code, before any
// command runs.
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)
}

gtbRoot.Execute silences Cobra's own error printing and routes any error returned from RunE through ErrorHandler at fatal level. The only os.Exit in main.go is the one for a construction error.

The Generated README

The scaffold ships a rich, generic README.md that orients a newcomer: the install command (go install <module>/cmd/<name>@latest), the just build/run recipes, a Develop section (project layout, the .gtb/manifest.yaml regeneration model, config and the environment-variable prefix), an "Enabled built-ins" list, Releasing, and links into the GTB documentation site. Every command and path it mentions exists in the generated tree.

Only one block is a placeholder: the "What is this?" section. Replace it with your product's description: everything else is accurate framework prose you can keep or trim. The README is yours: along with docs/index.md, justfile and the init seed config it is scaffolded once and then preserved on every gtb regenerate project, even under --overwrite allow, so your product blurb never needs defending. An organisation that wants a house-style README can supply one via a template overlay, which replaces this embedded default.

How to run it

Navigate to the directory where you want your project to live and run:

gtb generate project \
  --name "my-awesome-tool" \
  --repo "my-github-org/my-awesome-tool-repo" \
  --forge-backend github \
  --help-type slack \
  --slack-channel "#help" \
  --slack-team "My Team"

Interactive wizard

You don't have to remember all the flags. Run gtb generate project without --name (or without --repo for a hosted project) in a terminal and the wizard asks one page at a time. Pages appear only when they apply; each is one huh group, so shift+tab goes back a page and ctrl+c cancels.

Start
Name, description, destination path, the features to enable, whether the project is hosted on a forge (yes by default), and the help channel. Forges are not features here: the next page chooses one.
Forge (hosted on a forge: yes)
Forge Backend (GitHub, GitLab, Gitea, Codeberg or Bitbucket), the host (leave empty for the backend's own host, shown as the placeholder; set it for a self-managed instance), the repository as org/repo (or group/subgroup/repo on GitLab), whether it is private, and any other forges to capture credentials for (their init <forge> wizard and adapter, never the release source). Only GitHub and GitLab have a CI skeleton; the others get no CI files and the run says so.
Module (hosted on a forge: no)
The Go module path, since there is no host and repository to derive one from. A tool that is never imported can be a single word, and empty uses the project name, so the page never stops you going back to choose a forge after all.
Environment variable prefix
A choice: the prefix derived from the project name (my-app gives MY_APP, so MY_APP_LOG_LEVEL overrides log.level), which is the default; None, so environment variables never override config; or Other, which opens a page to type one. The flag takes the value itself and empty means none.
Self-update (the update feature selected)
The release channel, named for the forge chosen on the forge page and its host (GitLab releases (code.example.com)): the tool reads that repository's releases; the self-update policy (notify only, prompt, enforce); and the check interval. A self-updating tool cannot be generated without a channel, and the forge is the only channel in this release, so a project that is not hosted on a forge is refused here: go back and deselect Self-Update. A static release channel with no forge is being designed under #90.
Chat providers (always)
Which go/chat provider modules the binary links, recorded as chat.providers: a shortcut for wiring, for the tool's own code or for the ai feature, and independent of it. Per module: codex-local links chat-openai, which registers openai and openai-compatible too. Nothing is ticked unless ai was selected on the flags, in which case the default set is; the ai feature needs at least one.
AI defaults (the ai feature selected)
The default provider and optionally a model, for the AI-based features the ai flag switches (docs ask, init ai). One linked provider is its own default; between several you choose, the wizard never does. The default select narrows to what is ticked. See chat defaults.
AI endpoint (the default is openai-compatible or azure-openai)
The API endpoint (HTTPS), and for Azure the dated API version.
AI cloud addressing (the default is gemini-vertex or bedrock)
Optional project and region; the modules fall back to the platform's environment.
Telemetry (the telemetry feature selected)
Where usage events and OpenTelemetry data go; both optional.
MCP (the mcp feature selected)
The publication mode, compact (three discovery tools, the default) or direct (one native tool per command). On gtb wizard the page also lists the project's commands, ticked when they are on the MCP surface.
Help channel (Slack or Teams chosen)
Slack channel and team, or Teams channel and team.
Release signing (the update feature selected)
Whether to verify self-update downloads, and if so the WKD email, key source, key id, and whether to require a verified checksum on every update. Requiring a signature is not asked on a first run (it breaks every update until a signed release exists); --signing-require-signature and gtb enable signing --require-signature set it. Answering No after entering details discards them.

The same wizard runs again on an existing project as gtb wizard, pre-filled from the manifest; there the name is shown rather than asked, there is no destination page, the MCP page also asks which commands stay on the surface, and the signing page also asks whether to require a signature.

Available Flags

Flag Short Description Default
--name -n Name of your CLI tool β€”
--repo -r Repository in org/repo format β€”
--forge-backend Forge the project is hosted on (github, gitlab, gitea, codeberg, bitbucket) github
--host Git host (overrides backend default, for self-hosted instances) β€”
--private Mark the repository as private (requires a token for updates) false
--description -d Short description of the tool A tool built with gtb
--path -p Destination path for the generated project .
--features -f Features to enable: built-ins (update, init, docs, doctor, changelog, ai, config, telemetry, man) and the links keychain and mcp. A forge is chosen with --forge-backend, not here. Replaces the default set rather than extending it: see the generate reference update, init, mcp, docs, doctor, changelog, keychain
--go-version Go version for go.mod running toolchain version
--help-type Help channel type (slack, teams, or none) none
--overwrite How to handle file conflicts (allow, deny, or ask) ask
--slack-channel Slack channel (e.g. #my-team-help) β€”
--slack-team Slack team name (e.g. My Team) β€”
--teams-channel Microsoft Teams channel β€”
--teams-team Microsoft Teams team name β€”
--env-prefix Environment variable prefix for config overrides (e.g. MY_APP) β€”
--update-policy Self-update posture for the generated tool (disabled, prompt, or enabled); empty = framework default (disabled) β€”
--update-check-interval Baseline interval between self-update checks as a Go duration (e.g. 24h, 168h); empty = framework default (24h) β€”
--ci-component-source Override the phpboyscout/cicd component include base in the scaffolded GitLab pipeline gitlab.com/phpboyscout/cicd
--no-git Skip the post-generation git init and initial commit (init + commit is on by default) false
--push After the initial commit, add the derived remote as origin and push the default branch (push failures are non-fatal) false
--git-branch Default branch the initial commit lands on main
--signing Enable consumer-side release-signing verification (scaffolds internal/trustkeys and wires props.Signing) false
--signing-email Release WKD email for signing (external_key_email); implies --signing β€”
--signing-key-source Signing trust-anchor source (embedded, external, or both) both
--signing-require-external-crosscheck Fail signing closed when the external (WKD) resolver is unreachable false
--signing-key-id Signing key id/ARN/alias (or PEM path for local) the release pipeline signs with; wires the GoReleaser signs block β€”
--signing-backend gtb sign backend for the release pipeline aws-kms (when --signing-key-id is set)
--signing-kms-region AWS region for the aws-kms backend eu-west-2
--signing-public-key Path to the embedded public key the signature identifies internal/trustkeys/keys/signing-key-v1.asc
--template Custom template overlay source <src>@<ref> (local path or forge repo); repeatable, layered in order β€”
--dry-run Preview changes without writing files false

Custom Template Overlays

--template <src>@<ref> layers a custom template overlay over the embedded skeleton. Your own SECURITY.md, CODEOWNERS, a bespoke CI pipeline, etc. The flag is repeatable (sources layer in order, last writer wins). Manage sources on an existing project with the gtb template command group.

Dry-Run Mode

Use --dry-run to preview what generate project would produce without writing anything to disk:

gtb generate project --name "my-tool" --repo "org/my-tool" --dry-run

This materialises all generated files into a temporary directory, runs go mod tidy and golangci-lint run --fix, then shows a summary of files that would be created or modified along with unified diffs.

Tip

The --host flag is only needed for a self-managed instance. For github.com, gitlab.com, gitea.com, codeberg.org or bitbucket.org, the host follows --forge-backend.

Help Channel Configuration

The skeleton generator supports two built-in help channel types, which populate the Tool.Help field in the generated root command:

Slack. Users are directed to a Slack channel in error messages:

For assistance, contact My Team via Slack channel #support

Microsoft Teams. Users are directed to a Teams channel:

For assistance, contact My Team via Microsoft Teams channel Support

Both use the errorhandling.HelpConfig interface, so you can also provide a custom implementation.

Next Steps

Once your skeleton is generated, your project is ready to grow! Head over to the Command Generation guide to see how to add functionality.