Deep Cobra Integration for ErrorHandler Specification¶
- Authors
- Matt Cockayne, Claude (claude-sonnet-4-6) (AI drafting assistant)
- Date
- 18 March 2026
- Status
- IMPLEMENTED
Overview¶
GTB currently requires every command to explicitly call props.ErrorHandler.Fatal() in its Run function body β a boilerplate pattern that adds noise to every command and creates inconsistency where some commands bypass ErrorHandler entirely (e.g., using Logger.Fatalf directly).
Two additional problems exist:
PersistentPreRunEin the root command callsprops.Logger.Fatalf()directly on config-load failures, completely bypassing theErrorHandlerpipeline (no hints, no Slack help, no stack traces).- Cobra's default error output runs in parallel with GTB's β when
Execute()returns an error, Cobra may print it in its own format before GTB's handler gets a chance.
The goal is to make commands return errors idiomatically (via RunE) while routing all errors β including flag errors, PersistentPreRunE errors, and runtime errors β through StandardErrorHandler, which provides stack traces, hints, details, special sentinel handling, and Slack help.
Terminology¶
RunE- The Cobra command field that accepts a function returning
error. Cobra passes the returned error toExecute()for the caller to handle. - Execute wrapper
- A new
Execute(*cobra.Command, *props.Props)function inpkg/cmd/rootthat silences Cobra's own error output and routes any error fromrootCmd.Execute()throughErrorHandler.Check. SilenceErrors/SilenceUsage- Cobra fields that suppress default error/usage printing, giving GTB's
ErrorHandlerfull control of terminal output.
Design Decisions¶
RunE + Execute wrapper, not middleware: Commands use RunE and return errors naturally. An Execute() wrapper function in pkg/cmd/root silences Cobra's output, runs the command, and routes any returned error through ErrorHandler.Check. This is simpler than middleware/hook-chaining approaches.
Non-fatal errors: Commands that need to log a warning and continue call props.ErrorHandler.Warn() inside their body and return nil. No new error wrapping types are introduced.
ErrRunSubCommand usage output: PreRunE continues to call props.ErrorHandler.SetUsage(cmd.Usage) at its start. When RunE returns ErrRunSubCommand, handleSpecialErrors falls back to h.Usage (already set). This requires no change to the ErrorHandler interface.
Flag errors: SetFlagErrorFunc adds a "Run --help for usage" hint (using errors.WithHintf) and returns the error to propagate to Execute(). The wrapper then routes it through ErrorHandler.
handleOutdatedVersion: Currently calls props.ErrorHandler.Fatal() directly inside the PersistentPreRunE chain. Refactored to set result.Error and return; the caller in newRootPreRunE already checks updateResult.Error != nil and returns it, propagating to the Execute wrapper.
No ErrorHandler interface changes: The interface is unchanged. The Check(err, prefix, level, cmd...) signature is preserved.
Public API¶
New: pkg/cmd/root.Execute¶
// Execute runs the root command with centralized error handling.
// It silences Cobra's default error output and routes any error returned by
// the command tree through ErrorHandler.Check at Fatal level.
func Execute(rootCmd *cobra.Command, props *p.Props)
This replaces the pattern:
With:
Modified: internal/cmd/root.NewCmdRoot¶
// Before:
func NewCmdRoot(v ver.Info) *cobra.Command
// After:
func NewCmdRoot(v ver.Info) (*cobra.Command, *props.Props)
Returns props so main.go can pass it to Execute.
Internal Implementation¶
pkg/cmd/root/execute.go (NEW)¶
package root
import (
"github.com/cockroachdb/errors"
"github.com/phpboyscout/gtb/pkg/errorhandling"
p "github.com/phpboyscout/gtb/pkg/props"
"github.com/spf13/cobra"
)
func Execute(rootCmd *cobra.Command, props *p.Props) {
rootCmd.SilenceErrors = true
rootCmd.SilenceUsage = true
rootCmd.SetFlagErrorFunc(func(cmd *cobra.Command, err error) error {
return errors.WithHintf(err, "Run '%s --help' for usage.", cmd.CommandPath())
})
if err := rootCmd.Execute(); err != nil {
props.ErrorHandler.Check(err, "", errorhandling.LevelFatal)
}
}
pkg/cmd/root/root.go β Fix newRootPreRunE¶
Two props.Logger.Fatalf(...) calls become return errors.Wrap(...):
// Before:
flags, err := extractFlags(cmd)
if err != nil {
props.Logger.Fatalf("%s", err)
}
// After:
flags, err := extractFlags(cmd)
if err != nil {
return errors.Wrap(err, "failed to read command flags")
}
Same pattern for the loadAndMergeConfig call:
// Before:
cfg, err := loadAndMergeConfig(...)
if err != nil {
props.Logger.Fatalf("%s", err)
}
// After:
cfg, err := loadAndMergeConfig(...)
if err != nil {
return errors.Wrap(err, "failed to load configuration")
}
pkg/cmd/root/root.go β Fix handleOutdatedVersion¶
// Before:
if err := update.Update(ctx, props, "", false); err != nil {
if errors.Is(err, context.DeadlineExceeded) {
props.ErrorHandler.Fatal(errors.New("Update timed out: ..."))
}
props.ErrorHandler.Fatal(err)
}
// After:
if err := update.Update(ctx, props, "", false); err != nil {
if errors.Is(err, context.DeadlineExceeded) {
result.Error = errors.WithHint(
errors.New("update timed out"),
"Check your internet connection or try again later.")
return
}
result.Error = err
return
}
newRootPreRunE already checks updateResult.Error != nil β no change needed there.
Project Structure¶
pkg/cmd/root/
βββ execute.go β NEW: Execute() wrapper
βββ root.go β MODIFIED: fix newRootPreRunE and handleOutdatedVersion
βββ root_test.go β MODIFIED: new Execute tests, updated PreRunE tests
pkg/cmd/version/
βββ version.go β MODIFIED: Run β RunE
pkg/cmd/update/
βββ update.go β MODIFIED: Run β RunE
pkg/cmd/initialise/
βββ init.go β MODIFIED: Run β RunE
pkg/cmd/docs/
βββ docs.go β MODIFIED: Run β RunE, remove os.Exit(1)
internal/cmd/root/
βββ root.go β MODIFIED: NewCmdRoot returns (*cobra.Command, *props.Props)
cmd/gtb/
βββ main.go β MODIFIED: use pkgRoot.Execute(rootCmd, p)
internal/generator/templates/
βββ command.go β MODIFIED: RunβRunE, PreRunβPreRunE, PersistentPreRunβPersistentPreRunE
Error Handling¶
- Flag parse errors: Wrapped with
errors.WithHintfhint before being returned.Executewrapper routes throughErrorHandler.CheckatLevelFatal. - Config load errors:
newRootPreRunEreturnserrors.Wrap(err, "failed to load configuration"). Stack trace is preserved. - Update timeout:
result.Errorset witherrors.WithHint(errors.New("update timed out"), "Check your internet connection..."). - Command
RunEerrors: Returned from command, propagated torootCmd.Execute(), caught byExecutewrapper, routed toErrorHandler.CheckatLevelFatal. ErrRunSubCommand:handleSpecialErrorsinErrorHandlerdetects viaerrors.Isand callsh.Usage(). The usage func is registered viaPreRunE's call toprops.ErrorHandler.SetUsage(cmd.Usage)β no change required.ErrNotImplemented: Detected inhandleSpecialErrors, logs a warning. No process exit.
Phase 1 β Infrastructure¶
1.1 β Add Execute to pkg/cmd/root/execute.go (NEW FILE)¶
Create pkg/cmd/root/execute.go with the Execute wrapper (see Internal Implementation above).
1.2 β Fix newRootPreRunE in pkg/cmd/root/root.go¶
Replace both props.Logger.Fatalf("%s", err) calls with return errors.Wrap(err, "...").
1.3 β Fix handleOutdatedVersion in pkg/cmd/root/root.go¶
Replace props.ErrorHandler.Fatal(...) calls with result.Error = ...; return.
1.4 β Update internal/cmd/root/root.go¶
Refactor NewCmdRoot to return (*cobra.Command, *props.Props):
func NewCmdRoot(v ver.Info) (*cobra.Command, *props.Props) {
// ... construct p (unchanged) ...
rootCmd := root.NewCmdRoot(p)
// ... add subcommands (unchanged) ...
return rootCmd, p
}
1.5 β Update cmd/gtb/main.go¶
Phase 2 β Migrate GTB's Own Commands to RunE¶
Migrate the four GTB-owned command files from Run + explicit Fatal to RunE + error return.
| File | Change |
|---|---|
pkg/cmd/version/version.go |
Run β RunE, return errors.Wrap(err, "prefix") instead of ErrorHandler.Fatal(err, "prefix") |
pkg/cmd/update/update.go |
Run β RunE, return errors instead of ErrorHandler.Fatal / Logger.Fatalf |
pkg/cmd/initialise/init.go |
Run β RunE, return errors.Wrap(err, "...") instead of Logger.Fatalf |
pkg/cmd/docs/docs.go |
Run β RunE, return errors.WithHint(err, "...") instead of ErrorHandler.Fatal / os.Exit(1) |
Prefix context that was in ErrorHandler.Fatal(err, "prefix") becomes errors.Wrap(err, "prefix").
Phase 3 β Update Generator Templates¶
File: internal/generator/templates/command.go
generateCommandFields β Run β RunE¶
// Before:
jen.Id("Run").Op(":").Func()...Block(
jen.Id("props").Dot("ErrorHandler").Dot("Fatal").Call(
jen.Id("Run"+data.PascalName).Call(...),
jen.Lit("failed to run "+data.Name),
),
)
// After:
jen.Id("RunE").Op(":").Func()...Block(
jen.Return(
jen.Id("Run"+data.PascalName).Call(...),
),
)
generateCommandFields β PersistentPreRun β PersistentPreRunE¶
Cobra does not auto-chain PersistentPreRunE. Generated child commands must explicitly call the parent's hook:
// After:
jen.Id("PersistentPreRunE").Op(":").Func()...Block(
jen.If(jen.Id("cmd").Dot("Parent").Call().Op("!=").Nil().
Op("&&").Id("cmd").Dot("Parent").Call().Dot("PersistentPreRunE").Op("!=").Nil()).Block(
jen.If(jen.Id("err").Op(":=").Id("cmd").Dot("Parent").Call().
Dot("PersistentPreRunE").Call(jen.Id("cmd"), jen.Id("args")).Op(";").
Id("err").Op("!=").Nil()).Block(
jen.Return(jen.Id("err")),
),
),
jen.Return(
jen.Id("PersistentPreRun"+data.PascalName).Call(...),
),
)
generatePreRunField β PreRun β PreRunE¶
// After:
jen.Id("PreRunE").Op(":").Func()...Block(
// SetUsage call preserved (unchanged):
jen.Id("props").Dot("ErrorHandler").Dot("SetUsage").Call(jen.Id("cmd").Dot("Usage")),
// Fatal replaced with return:
jen.Return(jen.Id("PreRun"+data.PascalName).Call(...)),
// When data.PreRun is false:
jen.Return(jen.Nil()),
)
CommandInitializer β Run β RunE¶
// After:
jen.Id("RunE").Op(":").Func()...Block(
jen.Return(jen.Id("Init"+data.PascalName).Call(...))
)
Generated cmd.go files no longer need to import errorhandling β remove from generated imports.
Backwards Compatibility¶
- Existing consumer commands using
Run+ErrorHandler.Fatal()continue to work.Fatalcallsos.Exit(1)directly;Execute()never returns a non-nil error for those commands (process exits first). - The
ErrorHandlerinterface is unchanged. - Mocks (
mocks/pkg/errorhandling/ErrorHandler.go) require no changes. - Consumer tools calling
rootCmd.Execute()directly in theirmain.gostill work; they just don't get the centralized routing until they adoptpkgRoot.Execute(rootCmd, props).
Testing Strategy¶
pkg/cmd/root β New Tests¶
| Test | Scenario |
|---|---|
TestExecute_NilError |
Command returns nil β ErrorHandler.Check not called |
TestExecute_RuntimeError |
RunE returns error β mock ErrorHandler.Check called with LevelFatal |
TestExecute_FlagError |
Invalid flag β routed through handler with hint containing --help |
TestExecute_SilenceErrors |
Verify Cobra does not write to stderr |
TestNewRootPreRunE_FlagExtractError |
Returns error (was: called Fatalf, untestable) |
TestNewRootPreRunE_ConfigLoadError |
Returns error (was: called Fatalf, untestable) |
TestHandleOutdatedVersion_UpdateTimeout |
result.Error set with hint (was: called Fatal, untestable) |
Generator (internal/generator)¶
- Update
commands_lifecycle_unit_test.goexpectations: field namesRunE/PreRunE/PersistentPreRunE - Assert generated
cmd.godoes not containerrorhandlingimport - Assert
PersistentPreRunEbody contains parent-chaining block whenPersistentPreRun: true
pkg/cmd/version, update, initialise, docs¶
- Existing tests exercise command logic;
RunEmigration needs no new test cases - Any test asserting
os.Exitbehavior should be updated to assert the returned error
Migration & Compatibility¶
Consumer tools built on GTB that call rootCmd.Execute() directly in main.go continue to work without changes β the migration to pkgRoot.Execute is opt-in. Documentation will be updated to recommend the new pattern for new tools.
Future Considerations¶
- Middleware / hooks: If cross-cutting concerns (e.g., telemetry, audit logging) are needed in the future, the
Executewrapper is the natural integration point β add pre/post hooks there rather than in individual commands. - Structured exit codes: Currently
LevelFatalalways exits with code 1. Future work could map error types to specific exit codes viaErrorHandler.
Implementation Phases Summary¶
| Phase | Scope | Files Changed |
|---|---|---|
| 1 | Infrastructure | pkg/cmd/root/execute.go (new), pkg/cmd/root/root.go, internal/cmd/root/root.go, cmd/gtb/main.go |
| 2 | Migrate GTB commands | pkg/cmd/version/version.go, pkg/cmd/update/update.go, pkg/cmd/initialise/init.go, pkg/cmd/docs/docs.go |
| 3 | Generator templates | internal/generator/templates/command.go |
Verification¶
just build # confirm binary compiles
just test # full suite
go test -race ./pkg/cmd/... # migrated commands
go test -race ./internal/... # generator tests
golangci-lint run --fix
go run ./ generate test-cmd -p /tmp # verify generator output uses RunE
Check generated /tmp/ command file contains RunE: not Run:, and contains no ErrorHandler.Fatal.