Command Composition: setup.Command wrapper with Register¶
- Authors
- Matt Cockayne, Claude (claude-opus-4-8) (AI drafting assistant)
- Date
- 30 May 2026
- Status
- IMPLEMENTED
Overview¶
Subcommand registration in a generated tool currently goes through a free
function, setup.AddCommandWithMiddleware(parent, cmd, feature), whose third
argument is a props.FeatureCmd used only to look up which middleware should
wrap the command. The generator has to produce that feature key at every
registration site, and it produces the wrong thing for user commands, which
causes a generated project with nested commands not to compile.
This spec proposes composing *cobra.Command into a small setup.Command type
that carries its own Feature, and adding a Register method that wires
middleware from each child's own feature. The generated code becomes
self-describing (setup.Wrap(props.FeatureCmd("child"), cmd)) and registration
becomes a clean method call (parent.Register(child.NewCmdChild(p))), with no
feature key threaded through the call site. The
nested-command regression is fixed by construction.
This is a breaking change to the generated command surface (NewCmd<Name>
return types) and therefore targets v0.5.0. Because gtb regenerates all of
that code, the migration is "run gtb regenerate", not a hand-edit.
Background¶
How middleware is wired today¶
setup.AddCommandWithMiddleware (pkg/setup/middleware.go) wraps a command's
RunE with Chain(feature, runE), where Chain composes global middleware
plus featureMiddleware[feature] (middleware registered for that feature via
RegisterMiddleware). The feature argument is therefore a middleware lookup
key, not an enable/disable gate.
func AddCommandWithMiddleware(parent, cmd *cobra.Command, feature props.FeatureCmd) {
if cmd.RunE != nil {
cmd.RunE = Chain(feature, cmd.RunE)
}
for _, sub := range cmd.Commands() {
ApplyMiddlewareRecursively(sub, feature)
}
parent.AddCommand(cmd)
}
The framework's own root registers built-ins with this function, passing either a built-in feature constant or an empty feature:
// pkg/cmd/root/root.go
setup.AddCommandWithMiddleware(rootCmd, version.NewCmdVersion(props), "") // generic
setup.AddCommandWithMiddleware(rootCmd, update.NewCmdUpdate(props), p.UpdateCmd) // built-in feature
The regression¶
Commit 8974154 ("feat(setup): implement command middleware system and
generator integration") changed the generator's nested-command registration
(internal/generator/ast.go, createRegistrationStmts) from:
to:
setup.AddCommandWithMiddleware(cmd, child.NewCmdChild(p), props.ChildCmd) // after โ does not compile
props.<Name>Cmd constants (UpdateCmd, DocsCmd, โฆ) are hand-declared in
pkg/props/tool.go for built-in features only. The generator never creates a
props.ChildCmd for a user command, so the generated parent cmd.go references
an undefined symbol:
The bug only triggers for nested commands (a --parent that is itself a
user command), where the registration uses the wrapping path. Top-level commands
(--parent root) register directly inside NewCmdRoot and are unaffected. The
generator's integration tests assert on generated file content, never compile
the output, so CI stays green.
The established idiom elsewhere in the same generator is
props.FeatureCmd(name) โ e.g. the generated init.go already does
setup.Register(props.FeatureCmd("child"), โฆ). The nested-registration path is
the lone deviation that assumed a constant.
Proposed design¶
setup.Command¶
A new type in pkg/setup composes the cobra command with its feature.
// pkg/setup/command.go
package setup
// Command composes *cobra.Command with the feature it belongs to, so that
// registering a subcommand also wires up its middleware.
type Command struct {
*cobra.Command
Feature props.FeatureCmd
}
// Wrap pairs a cobra command with its feature.
func Wrap(feature props.FeatureCmd, cmd *cobra.Command) *Command {
return &Command{Command: cmd, Feature: feature}
}
// Register adds child commands to this command, wiring each child's middleware
// from the child's own feature. A child's descendants are wired when the child
// registers them, so Register only wraps the immediate child's RunE โ there is
// no recursive re-wrapping.
func (c *Command) Register(children ...*Command) {
for _, child := range children {
if child.RunE != nil {
child.RunE = Chain(child.Feature, child.RunE)
}
c.AddCommand(child.Command)
}
}
*Command embeds *cobra.Command, so it is usable anywhere a cobra command's
methods are needed; code that needs the raw *cobra.Command (e.g. to pass to a
cobra API) uses .Command.
Generated code, before and after¶
Command file (pkg/cmd/child/cmd.go):
// before
func NewCmdChild(props *props.Props) *cobra.Command {
cmd := &cobra.Command{Use: "child", RunE: ...}
return cmd
}
// after โ the command owns its feature identity
func NewCmdChild(props *props.Props) *setup.Command {
cmd := &cobra.Command{Use: "child", RunE: ...}
return setup.Wrap(props.FeatureCmd("child"), cmd)
}
Parent registration (pkg/cmd/parent/cmd.go):
// before (the buggy wrapping path)
setup.AddCommandWithMiddleware(cmd, child.NewCmdChild(p), props.ChildCmd)
// after โ clean, no feature key at the call site
parent.Register(child.NewCmdChild(p))
Root (pkg/cmd/root/cmd.go, generated) registers its top-level commands the same
way, e.g. rootCmd.Register(hello.NewCmdHello(p), greet.NewCmdGreet(p)).
Affected components and implementation plan¶
pkg/setup/command.go(new): theCommandtype,Wrap,Register. Reuse the existingChainfrompkg/setup/middleware.go.pkg/cmd/root(root.go):NewCmdRootreturns*setup.Command;Executeaccepts*setup.Command(or keeps*cobra.Commandand callers pass.Command). Built-in registrations migrate fromAddCommandWithMiddleware(rootCmd, โฆ, feature)to theRegistermethod, with built-in features still supplied viasetup.Wrap(p.UpdateCmd, โฆ)at each built-in command's construction (or an explicit feature onWrap).- Generator templates:
internal/generator/templates/command.go(CommandRegistration):NewCmd<Name>returns*setup.Command, wrapping withsetup.Wrap(props.FeatureCmd("<name>"), cmd).internal/generator/templates/skeleton_root.go:NewCmdRootreturns*setup.Command; root registration uses.Register(...).internal/generator/ast.go(createRegistrationStmts/insertIntoRoot): emit<parent>.Register(<pkg>.NewCmd<Name>(p))instead ofsetup.AddCommandWithMiddleware(...)/ directAddCommand. This removes theprops.<Name>Cmdselector entirely and makes the unused-setup-import fix moot for this path (the parent now referencessetup.Command/Register, which is a real use).- Generated
main.go:gtbRoot.Execute(rootCmd, p)continues to work; adjust for the new root type as needed (.CommandifExecutestays*cobra.Command). AddCommandWithMiddleware: keep it (now implemented in terms of the sameChain) for backward compatibility and for any non-generated callers, or deprecate it. See open questions.
Middleware semantics¶
- Each command's
RunEis wrapped exactly once, with its own feature, at the point its parent callsRegister. Because each command registers its own children inside itsNewCmd<Name>, the whole tree is wired by composition and no command is wrapped twice. This is cleaner than the currentApplyMiddlewareRecursively, which re-applies the parent's feature down the subtree. - A user command's feature (
props.FeatureCmd("child")) has no registered feature-specific middleware unless the author adds some viaRegisterMiddleware(props.FeatureCmd("child"), โฆ), in which case it now has a natural, stable key to target. Global middleware always applies. - Behaviour for built-in features is unchanged as long as their
Wrapuses the samep.<Feature>Cmdconstant the root passes today.
Migration and compatibility¶
- Breaking:
NewCmd<Name>andNewCmdRootchange return type from*cobra.Commandto*setup.Command. This ripples toExecuteand generatedmain.go. - Migration is regeneration. All affected code is generated;
gtb regeneraterewrites it. Hand-written code that calledNewCmd<Name>expecting a*cobra.Commanduses.Command(the embedded field). - Versioning: target v0.5.0 (pre-1.0, so a minor that requires
regeneration is acceptable per the project's 0.x policy). Add an entry to
docs/migration/describing the regenerate step.
Out of scope¶
- Changing what middleware does, or the global/feature middleware registries.
- The enable/disable feature-flag system (
props.Toolfeatures) โFeaturehere is only a middleware key. - Converting
AddCommandWithMiddlewarecallers outside the generator (decide in open questions).
Alternative considered: cobra Annotations (non-breaking)¶
Instead of changing return types, store the feature in cobra's built-in
Annotations map[string]string and provide a free
setup.Register(parent *cobra.Command, children ...*cobra.Command) that reads
child.Annotations[FeatureKey]. NewCmd<Name> keeps returning *cobra.Command
(non-breaking), the feature stays co-located, and the call site stays clean. The
cost is a stringly-typed metadata channel and slightly more implicit wiring.
Chosen against in favour of the typed setup.Command for clarity, but it is the
fallback if a breaking change is undesirable before v1.0.
Resolutions¶
The originally-listed open questions were resolved during spec review (2026-05-30):
Wrapsignature.func Wrap(feature props.FeatureCmd, cmd *cobra.Command) *Commandโ feature first (the lookup-key-first convention) and the name Wrap reads cleanly at call sites.AddCommandWithMiddleware. Kept as a thin deprecated shim that delegates toRegister, with a// Deprecated:marker pointing atCommand.Register. Removal targeted at v1.0; protects any downstream callers.Executesignature. Changes to accept*setup.Command. Generatedmain.gopasses the typed root directly. Keeps the migration consistent end to end.- Regression coverage. A gated generator integration test scaffolds a project with a nested command and runs
go buildof the generated module. This is the bug-class catcher the file-content-only tests missed; it lands alongside the generator changes.
The interim stopgap (a v0.4.2 one-line generator fix) is not pursued โ the redesign supersedes it.
Test plan¶
- Unit:
setup.Command.Registerwraps the immediate child'sRunEwith the child's feature; does not double-wrap descendants;AddCommandis called. - Generator: generated
NewCmd<Name>returns*setup.Commandand wraps withsetup.Wrap(props.FeatureCmd("<name>"), โฆ); parent/root use.Register(...). - Integration (the gap that let the regression through): scaffold a project, add
a nested command, and
go buildit โ it must compile. ExtendTestFullLifecycle/ add an e2e that builds the generated module.