Skip to content

Root Command

Root Command Architecture

Lifecycle Hooks (PersistentPreRunE)

Before any subcommand is executed, the root command performs the following automated steps:

  1. Flag Extraction: Validates and parses the global flags.
  2. Configuration Loading: Builds the layered config.Store (embedded defaults, config files, env, and changed flags) and pins it on props.Config.
  3. Logging Setup: Configures the global props.Logger level and format based on flags and config.
  4. Update Checking: Optionally performs a background check for newer versions (unless --ci is set or the check was done in the last 24 hours).

The auxiliary fast path

Not every command should pay the bootstrap. Before configuration is even loaded, the pre-run returns early, skipping config load, telemetry consent, collector wiring, and the update check, while still honouring --debug for logging, for:

  • The init subtree. init is what creates the configuration, and its provider subcommands (init github, init bitbucket, …) must equally run on a configless machine. The subtree is identified by walking up the command tree for the InitCmd feature annotation stamped by setup.Wrap. Never the fragile Use string.
  • Cobra's own generated auxiliary commands: help, completion (and its shell subcommands), and the hidden __complete used by shell tab-completion. On a fresh install they must not fail the missing-config gate, and tab-completion must never pay the bootstrap (least of all a network update check) on every keystroke. Only cobra's own instances are exempted: they are direct children of the root carrying no annotations, whereas every GTB-wrapped command is stamped by setup.Wrap, so a downstream feature command that happens to be named help or completion still gets the normal bootstrap.
  • Commands listed in Tool.Bootstrap.AuxiliaryCommands, so a downstream tool can extend the set (e.g. for its own bootstrap-free plumbing commands) without a framework release. Entries match a command's Name() or full CommandPath(), like Bootstrap.SkipConfigCheck. Note this is stronger than SkipConfigCheck: an auxiliary command runs with props.Config left nil, so it must read nothing from configuration.

Update-check exemptions

The pre-run update check is likewise annotation-driven, not name-driven. setup.SkipUpdateCheck exempts a command when it, or any ancestor, so a stamped group covers its subtree, carries the setup.MarkSkipUpdateCheck annotation or is wrapped with the UpdateCmd/InitCmd feature. Built-ins stamp themselves (version prints its own check, doctor diagnoses the install it has, mcp must keep protocol stdout clean); a downstream command opts out with setup.MarkSkipUpdateCheck(cmd).

Unattended runs

A run is unattended when nobody can answer a question: it is under CI (the --ci flag, the ci key or CI=true), or its stdin is not a terminal (a service under systemd, a pod, cron, a script, piped input). The test is the terminal alone, not the accessible-mode rule the setup wizards use, because an unsolicited prompt on the start path must never read a piped stdin: under MCP stdio that stdin is the protocol. An unattended run:

  • makes no update check, so no policy blocks it and nothing self-updates. Under the enabled policy it logs one warning that the policy was not enforced;
  • shows no out-of-date reminder and no telemetry consent prompt;
  • needs no config file (see below).

Everything else in the pre-run runs as usual. See spec 0208.

The missing-config gate

When the init feature is enabled, configuration is treated as required: if no config file exists, loading returns ErrNoConfigFile rather than running the command against bare defaults. An unattended run is the exception: nobody can run init there, so it starts on the embedded defaults, the environment and the flags, and logs that it did. Required config sources and the refusal of a config file in a format the tool no longer reads still apply. Two escape hatches relax the gate for a person too:

  • Auto-initialise: with Tool.Bootstrap.AutoInitialise set, the pre-run heals a missing config by running a non-interactive init (writing the default config) and then loading it for real, so the command proceeds.
  • Config-independent commands skip the gate: version, changelog, man, and docs read nothing from configuration, so requiring one before they run would be a fresh-install papercut. They carry the setup.SkipConfigCheck annotation, which relaxes the gate to a tolerant load (embedded defaults, no error), props.Config is still populated, so any incidental read is safe. The init subtree and cobra's generated help/completion commands skip config loading entirely (see the auxiliary fast path above). A command that owns its own bootstrap can opt in with setup.SkipConfigCheck(cmd) or Tool.Bootstrap.SkipConfigCheck.

When the gate does fire: a config-gated command run at a terminal on a machine with no config file. The resulting error carries a hint naming the fix: Run '<tool> init' to create a configuration.

Signal Handling

root.Execute runs the command tree with a signal-aware execution context: it derives a cancellable context watching os.Interrupt (SIGINT/Ctrl-C), syscall.SIGTERM and syscall.SIGHUP, and passes it to Cobra via ExecuteContext, so every command's cmd.Context() is cancelled on interruption. A SIGINT or SIGHUP the process started with ignored, as a background job (tool &) or nohup arranges, is left ignored: subscribing would undo it. SIGTERM is always taken, because Go never keeps it ignored.

The lifecycle mirrors kubectl/docker:

  1. First signal: cancels cmd.Context(). Long-running commands observing ctx.Done() unwind gracefully; the deferred telemetry flush still runs (on a bounded background context, so cancellation cannot abort the flush itself).
  2. Second signal: ends the run immediately, so a hung cleanup can never trap the user.
  3. Ending by the signal: once the drain is done, the root restores the signal's default action and sends it to its own process, so the process dies by that signal. A shell still sees 128 + signum in $? (130 for SIGINT, 143 for SIGTERM, 129 for SIGHUP). A process manager reading the wait status sees a stop by the signal, which systemd counts as clean without any SuccessExitStatus= line. If the signal cannot land, the root exits 128 + signum instead.
  4. A failed drain: if the command returns an error during its drain that is not the cancellation (nil, or an error wrapping context.Canceled), it is reported like any other failure and the run exits with that error's code, so a process manager sees the failure.

A command that loses its terminal (SIGHUP) drains too, but anything it prints to that terminal while draining may be lost. To outlive an SSH session, run it under tmux, systemd-run --user or nohup.

An interrupt is a deliberate user choice, not a failure, so the interrupted by signal: … notice is logged at debug, not error (it routes through errorhandling.LevelFatalQuiet, which exits like LevelFatal but logs at debug). End users see a clean stop; --debug still surfaces the notice.

On Windows only os.Interrupt is deliverable, there is no SIGHUP, and a process cannot end itself by a signal, so the run exits 128 + signum there.

A tool that opts out with root.WithoutSignals() owns all of this itself: cancelling the context, ending by the signal, and leaving ignored signals alone.

The framework owns signals, and why that matters

Signal disposition is process-global: signal.Notify is additive, so every registered channel receives a copy of every signal. Whatever registers a handler becomes an owner of that global, and two owners means two shutdown drivers racing on a single Ctrl-C.

The framework claims that ownership because it has framework-wide work to do on interruption (flushing buffered telemetry, cleaning up a half-written self-update) that individual commands know nothing about.

The corollary is that a component beneath the framework must not register its own handler. A service supervisor such as go/controls observes the context the framework cancels; it does not compete for the signal. That is the intended arrangement and needs no configuration.

Opting out

A tool that genuinely needs to own signal disposition itself can decline the framework's handler:

gtbRoot.Execute(rootCmd, p, gtbRoot.WithoutSignals())

Having opted out, the tool owns the whole contract the framework otherwise provides: cancelling the command context, flushing telemetry before exit, and choosing an exit code. This is deliberately a rare need: reach for it only when something in the tool must genuinely be the signal owner.

Interactive prompts own Ctrl-C

While a TUI prompt (Huh/Bubble Tea) is active, the terminal is in raw mode, so Ctrl-C arrives as a keystroke: it aborts the current prompt and never raises SIGINT. The outer signal context therefore only acts when no TUI is reading the keyboard. An external kill -INT/kill -TERM still cancels the whole run, which is the desired semantic for supervisors.

Commands should treat cmd.Context() as the single cancellation source:

RunE: func(cmd *cobra.Command, args []string) error {
    select {
    case <-cmd.Context().Done():
        return cmd.Context().Err() // graceful unwind on Ctrl-C / SIGTERM
    case result := <-work:
        return handle(result)
    }
},

Implementation

The root command is implemented in cmd/root/root.go and created via the root.NewCmdRoot(props) entry point.

For more information on the dependency injection pattern used here, see the Props Documentation.