Root Command¶
Root Command Architecture¶
Lifecycle Hooks (PersistentPreRunE)¶
Before any subcommand is executed, the root command performs the following automated steps:
- Flag Extraction: Validates and parses the global flags.
- Configuration Loading: Builds the layered
config.Store(embedded defaults, config files, env, and changed flags) and pins it onprops.Config. - Logging Setup: Configures the global
props.Loggerlevel and format based on flags and config. - Update Checking: Optionally performs a background check for newer versions (unless
--ciis 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
initsubtree.initis 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 theInitCmdfeature annotation stamped bysetup.Wrap. Never the fragileUsestring. - Cobra's own generated auxiliary commands:
help,completion(and its shell subcommands), and the hidden__completeused 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 bysetup.Wrap, so a downstream feature command that happens to be namedhelporcompletionstill 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'sName()or fullCommandPath(), likeBootstrap.SkipConfigCheck. Note this is stronger thanSkipConfigCheck: an auxiliary command runs withprops.Configleft 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
enabledpolicy 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.AutoInitialiseset, the pre-run heals a missing config by running a non-interactiveinit(writing the default config) and then loading it for real, so the command proceeds. - Config-independent commands skip the gate:
version,changelog,man, anddocsread nothing from configuration, so requiring one before they run would be a fresh-install papercut. They carry thesetup.SkipConfigCheckannotation, which relaxes the gate to a tolerant load (embedded defaults, no error),props.Configis still populated, so any incidental read is safe. Theinitsubtree 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 withsetup.SkipConfigCheck(cmd)orTool.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:
- First signal: cancels
cmd.Context(). Long-running commands observingctx.Done()unwind gracefully; the deferred telemetry flush still runs (on a bounded background context, so cancellation cannot abort the flush itself). - Second signal: ends the run immediately, so a hung cleanup can never trap the user.
- 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 + signumin$?(130for SIGINT,143for SIGTERM,129for SIGHUP). A process manager reading the wait status sees a stop by the signal, which systemd counts as clean without anySuccessExitStatus=line. If the signal cannot land, the root exits128 + signuminstead. - 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:
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.