gtb enable/disable <feature> β manifest-driven feature toggles that survive regeneration¶
- Authors
- Matt Cockayne
- Date
- 16 June 2026
- Status
- IMPLEMENTED
Summary¶
A GTB-generated project wires its built-in feature set
(props.Tool.Features = props.SetFeatures(...)) into the generated root
command, pkg/cmd/root/cmd.go, which carries a // Code generated by gtb. DO
NOT EDIT. header. That wiring is driven by the manifest's properties.features
block, and β verified β survives regenerate project when the features are
in the manifest (e.g. set at creation time via generate project --features β¦).
The gap (keryx bug-report #8 part 2): there is no way to change the feature
set after creation other than hand-editing that DO-NOT-EDIT file. A user who
realises post-generation that they need, say, ai enabled, edits the root
command directly; the next regenerate project faithfully rewrites it from the
manifest and drops their edit. gtb enable/disable exists today but only
knows signing.
This spec adds gtb enable <feature> and gtb disable <feature> for the
built-in features. They flip properties.features in the manifest and re-render
the generated wiring β exactly the pattern gtb enable signing already uses for
the signing block β so feature changes are manifest-driven and regenerate-safe.
Motivation¶
regenerate project⦠dropped theFeatures: props.SetFeatures(...)line.
Diagnosis: the generated root is correctly regenerated from the manifest; the
"drop" only happens because the user hand-edited a generated file instead of
recording the change in the manifest. The real missing capability is a
first-class, manifest-driven way to toggle a feature post-generation. The
framework already models this for signing
(docs/development/specs/2026-06-10-signing-generator-feature.md,
internal/cmd/enable, internal/cmd/disable); features should work the same
way.
Background β how features flow today¶
- Feature constants β
pkg/props/tool.go:UpdateCmd,InitCmd,McpCmd,DocsCmd,AiCmd,ConfigCmd,DoctorCmd,ChangelogCmd,TelemetryCmd; default-enabled set inDefaultFeatures. - Manifest β
properties.features: [{name, enabled}](internal/generator/manifest.goManifestFeature). - Generation β
internal/generator/skeleton.gocomputesEnabledFeatures/DisabledFeaturesfrom the manifest; the root template (internal/generator/templates/skeleton_root.go) emitsFeatures: props.SetFeatures(...)only when there is at least one non-default enable/disable. - Generate flag β
generate project --features ai,config,β¦records the set at creation and round-trips throughregenerate projectcorrectly. - Existing toggles β
internal/cmd/enableandinternal/cmd/disable, today wired only withsigningsubcommands, re-render root from the manifest.
Design¶
CLI surface¶
gtb enable <feature>... # e.g. gtb enable ai / gtb enable ai config
gtb disable <feature>... # e.g. gtb disable doctor
<feature> is one of the toggleable built-ins (the set the generator already
understands via --features): ai, config, telemetry, init, update,
mcp, docs, doctor, changelog. One or more may be given in a single
invocation; the manifest is updated for each and the root is re-rendered once.
The command:
- Loads
.gtb/manifest.yaml(errors clearly if not a gtb project, like the otherinternal/cmdverbs). - Validates
<feature>against the known toggleable set (clear error listing the valid names otherwise). - Sets
properties.features[<feature>].enabled = true|false(adding the entry if absent), idempotently. - Re-renders the generated root command from the manifest through the existing hash-protected skeleton render path (so a hand-edited, hash-diverged root is not silently clobbered β it warns, consistent with the rest of regenerate).
- Reports what changed (e.g.
enabled feature "ai"), or that it was already in the requested state.
This mirrors enable/disable signing end-to-end; the difference is the target
is a properties.features entry rather than the signing block, and there is no
trustkeys/scaffolding side effect β only the root re-render.
Composition with --features, generate, and regenerate¶
generate project --features β¦is unchanged (sets the initial set).gtb enable/disable <feature>is the post-generation editor of the same manifest block.regenerate projectcontinues to render from the manifest, so a feature toggled via these commands persists across regenerations β closing the gap.
Defaults & the "only non-default emits Features" rule¶
The root template only emits the Features: field when the manifest differs
from the framework defaults. enable/disable must therefore be able to
remove an entry that returns a feature to its default state (so the rendered
root drops the now-redundant SetFeatures call), as well as add/flip one. The
command normalises properties.features to the minimal set needed to express
the delta from DefaultFeatures.
Implementation¶
internal/cmd/enable/enable.go/internal/cmd/disable/disable.go(OQ1 resolved β positional): the feature is a positional argument on theenable/disableparent command itself (gtb enable ai).signing(and any future configuration-heavy capability) stays a scoped subcommand with its own flags. cobra routes a first argument that matches a registered subcommand (signing) there, and a non-matching first argument falls through to the parent'sRunEβ so a simpleMaximumNArgs(1)+RunEon the parent gives positional feature toggles without polluting it with signing's flags. With no argument, the parent opens the interactive multi-select picker.- Shared toggle helper (
internal/cmd/feature_toggle.go):RunFeatureToggleresolves the positional name (or the picker) into a desired featureβstate map and calls the generator; bothenableanddisableparents delegate to it. - Generator (
internal/generator/features.go):ApplyFeatures(ctx, desired)sets/clears eachManifestFeature, normalised againstDefaultFeatures, then re-renders root viaregenerateRootCommand(the signing path'sapplySigningPosture-style root render).CurrentFeatures/FeatureEnabledback the picker. - Validation: a
ToggleableFeaturesset + aValidateFeatureNamehelper with a clear "unknown feature (valid: β¦)" error. - Docs:
docs/components/generator/enable page; note in the generated project README / tutorial that features are changed viagtb enable/disable, not by editing the DO-NOT-EDIT root.
Testing¶
- Unit: enabling a default-off feature adds the manifest entry and the rendered
root contains
props.Enable(props.AiCmd); disabling a default-on feature addsprops.Disable(...); returning a feature to its default removes the entry and the rendered root drops the redundantSetFeatures(or omits the toggle); unknown feature name is rejected; idempotent re-run is a no-op. - Regression: the keryx scenario β
gtb enable aithengtb regenerate projectkeepsprops.Enable(props.AiCmd)(the feature is not dropped). - E2E (Godog, gated):
gtb enable <feature>/disable <feature>round-trip on a generated project (CLI behaviour β Gherkin per the BDD strategy).
Out of scope¶
- Healing projects whose feature wiring was previously hand-edited and lost β
the user re-runs
gtb enable <feature>to record it in the manifest. - Toggling
signing(already covered byenable/disable signing). - Preventing manual edits to generated files (regenerate already warns on hash divergence; a stronger guard is a separate concern).
Open questions¶
All three resolved 2026-06-16 (author):
- Command shape β RESOLVED: positional. The feature is a positional
argument on the
enable/disableparent (gtb enable ai); there is nofeaturesubcommand. Capabilities with their own configuration (signing, and any future complex ones) remain scoped subcommands so their flags don't leak onto the parent β cobra routes a first arg matching a subcommand there, otherwise it falls through to the parent's positional handler. (An earlier pass briefly implemented afeaturesubcommand and a fully-unifiedenable <capability>that folded signing in; both were rejected β the former for the extra noun, the latter because signing's eight flags then polluted the shared command.) - Which features are toggleable β RESOLVED: all nine (
ai,config,telemetry,init,update,mcp,docs,doctor,changelog). Document theupdateβForcedUpdate interaction: disablingupdateremoves the self-update subsystem (and therefore the ForcedUpdate check) from the tool entirely. - Interactive vs non-interactive β RESOLVED: non-interactive when a feature
name is given (CI-safe); when no name is given, the parent command opens
an interactive multi-select wizard of candidate features (the disabled ones
for
enable, the enabled ones fordisable). The wizard is suppressed under--ci/non-interactive sessions, which instead error asking for an explicit feature name.
Filed as the remediation spec for keryx bug-report #8 part 2 (part 1 β add-flag wiping Short/Long β was already fixed on main, commit b712170). Draft for review β please resolve the open questions before implementation.