Skip to content

Adding Nested Subcommands

GTB's command composition model (since v0.5) makes nested command trees straightforward: every NewCmd<Name> returns *setup.Command, and parents attach children via parent.Register(child...). This guide walks through adding a tool deploy canary command โ€” first using the generator, then showing the same shape by hand.

The end result is identical either way; the generator simply emits what you would write yourself.


Option A: Use the generator

# Scaffold a project (skip if you already have one)
gtb generate skeleton \
    --name my-tool \
    --repo example/my-tool \
    --description "My tool" \
    --features init,update,doctor,config \
    --path .

# Add a root-level command
gtb generate command --name deploy --short "Deploy stuff"

# Add a nested subcommand under deploy
gtb generate command --name canary --parent deploy --short "Canary deploy"

The generator updates three things in one pass per command:

  1. Creates pkg/cmd/<parent>/<name>/cmd.go with the canonical NewCmd<Name> constructor returning *setup.Command.
  2. Edits the parent's cmd.go to add the import and a cmd.Register(<pkg>.NewCmd<Name>(props)) call.
  3. Updates .gtb/manifest.yaml so gtb regenerate knows the command tree.

Running go build ./cmd/<tool> after that produces a binary with the full tool deploy canary tree visible in --help.


Option B: Write the constructors by hand

1. The leaf command โ€” canary

// pkg/cmd/deploy/canary/cmd.go
package canary

import (
    "github.com/spf13/cobra"

    "gitlab.com/phpboyscout/go-tool-base/pkg/props"
    "gitlab.com/phpboyscout/go-tool-base/pkg/setup"
)

func NewCmdCanary(p *props.Props) *setup.Command {
    return setup.Wrap("canary", &cobra.Command{
        Use:   "canary",
        Short: "Canary deploy",
        RunE: func(cmd *cobra.Command, args []string) error {
            p.Logger.Info("running canary deploy")
            return nil
        },
    })
}

2. The parent command โ€” deploy

The parent attaches its children with Register before returning:

// pkg/cmd/deploy/cmd.go
package deploy

import (
    "github.com/spf13/cobra"

    "github.com/example/my-tool/pkg/cmd/deploy/canary"
    "gitlab.com/phpboyscout/go-tool-base/pkg/props"
    "gitlab.com/phpboyscout/go-tool-base/pkg/setup"
)

func NewCmdDeploy(p *props.Props) *setup.Command {
    cmd := setup.Wrap("deploy", &cobra.Command{
        Use:   "deploy",
        Short: "Deploy stuff",
    })

    cmd.Register(canary.NewCmdCanary(p))

    return cmd
}

Two important details:

  • cmd := setup.Wrap(...) assigns the composed type to cmd from the start. Every later mutation โ€” cmd.Flags(), cmd.MarkFlagsMutuallyExclusive(...), cmd.Register(child) โ€” flows through the embedded *cobra.Command.
  • The parent does not thread a feature key into Register. Each child owns its own feature via its own setup.Wrap call โ€” siblings can have completely different middleware stacks without the parent knowing.

3. Attach deploy to the root

// pkg/cmd/root/cmd.go
rootCmd := gtbRoot.NewCmdRoot(p,
    deploy.NewCmdDeploy(p),
)

root.NewCmdRoot(p, children...) takes a variadic of *setup.Command and calls Register internally, so the variadic form and the explicit rootCmd.Register(child) form are equivalent.


Verifying the tree compiles and runs

go build ./...
./my-tool --help
# ...
# Available Commands:
#   deploy      Deploy stuff
#   ...

./my-tool deploy --help
# Available Commands:
#   canary      Canary deploy

./my-tool deploy canary --help
# Usage:
#   my-tool deploy canary [flags]

If the build fails with cmd.Register undefined (type *cobra.Command has no field or method Register), the parent's cmd variable was typed as *cobra.Command โ€” likely from an older template. Re-run gtb regenerate or update the constructor to use cmd := setup.Wrap(...) instead of cmd := &cobra.Command{...}.


What the generator does that you should not skip

The generator's emission is idempotent: re-running gtb generate command --name canary --parent deploy does not double-register the child, because the AST inserter detects both cmd.Register(...) and legacy setup.AddCommandWithMiddleware(...) shapes and short-circuits. If you wire commands by hand you don't get this for free โ€” adding the same child twice will compile but produce a runtime "command already added" panic from cobra.

If you mix generated and hand-written commands in the same project:

  • Keep hand-written commands in packages the generator does not own (anything not listed in .gtb/manifest.yaml).
  • For generated parents, prefer gtb generate command --parent <name> to add children โ€” your hand edits inside a generated file will survive only if they sit outside the regions the generator rewrites.

See also