Skip to content

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 the Features: 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 in DefaultFeatures.
  • Manifest β€” properties.features: [{name, enabled}] (internal/generator/manifest.go ManifestFeature).
  • Generation β€” internal/generator/skeleton.go computes EnabledFeatures/DisabledFeatures from the manifest; the root template (internal/generator/templates/skeleton_root.go) emits Features: 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 through regenerate project correctly.
  • Existing toggles β€” internal/cmd/enable and internal/cmd/disable, today wired only with signing subcommands, 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:

  1. Loads .gtb/manifest.yaml (errors clearly if not a gtb project, like the other internal/cmd verbs).
  2. Validates <feature> against the known toggleable set (clear error listing the valid names otherwise).
  3. Sets properties.features[<feature>].enabled = true|false (adding the entry if absent), idempotently.
  4. 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).
  5. 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 project continues 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 the enable/disable parent 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's RunE β€” so a simple MaximumNArgs(1) + RunE on 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): RunFeatureToggle resolves the positional name (or the picker) into a desired featureβ†’state map and calls the generator; both enable and disable parents delegate to it.
  • Generator (internal/generator/features.go): ApplyFeatures(ctx, desired) sets/clears each ManifestFeature, normalised against DefaultFeatures, then re-renders root via regenerateRootCommand (the signing path's applySigningPosture-style root render). CurrentFeatures/FeatureEnabled back the picker.
  • Validation: a ToggleableFeatures set + a ValidateFeatureName helper with a clear "unknown feature (valid: …)" error.
  • Docs: docs/components/ generator/enable page; note in the generated project README / tutorial that features are changed via gtb 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 adds props.Disable(...); returning a feature to its default removes the entry and the rendered root drops the redundant SetFeatures (or omits the toggle); unknown feature name is rejected; idempotent re-run is a no-op.
  • Regression: the keryx scenario β€” gtb enable ai then gtb regenerate project keeps props.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 by enable/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):

  1. Command shape β€” RESOLVED: positional. The feature is a positional argument on the enable/disable parent (gtb enable ai); there is no feature subcommand. 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 a feature subcommand and a fully-unified enable <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.)
  2. Which features are toggleable β€” RESOLVED: all nine (ai, config, telemetry, init, update, mcp, docs, doctor, changelog). Document the update↔ForcedUpdate interaction: disabling update removes the self-update subsystem (and therefore the ForcedUpdate check) from the tool entirely.
  3. 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 for disable). 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.