SPEC 7: Programmatic Config Access¶
- Authors
- Matt Cockayne, Claude (claude-opus-4-6) (AI drafting assistant)
- Date
- 24 March 2026
- Status
- IN PROGRESS
Overview¶
GTB already provides two mechanisms for managing configuration:
- Direct YAML editing โ the config file can be edited by hand.
initcommand + Initialiser pattern โinitruns registeredInitialiserimplementations sequentially to guide users through interactive TUI-driven setup. Individual subsystems can be reconfigured independently viainit <subsystem>(e.g.init ai,init github), each providing structured forms that only ask for what that subsystem needs.
What is missing is programmatic, non-interactive access to individual config keys. There is no way to read a single value from a shell script, set a value in CI without editing YAML directly, or validate the current config independently of running a full initialisation flow.
This spec introduces a config subcommand with four operations:
config get <key>โ read and display a single config value using dot-notationconfig set <key> <value>โ write a single config value to the config fileconfig listโ display all resolved config keys and values, masking sensitive entriesconfig validateโ check the current config against required key definitions and report problems
The command is gated behind a ConfigCmd feature flag, disabled by default, and is only valuable in tools with local file-based configuration (see When to Enable).
When to Enable¶
Both ConfigCmd and InitCmd are only relevant when a tool manages configuration in local YAML files โ typically a developer-facing CLI tool. They should be disabled for tools deployed as containerised services or accessed via API, where configuration comes from environment variables or mounted secrets.
| Deployment model | InitCmd |
ConfigCmd |
|---|---|---|
| Developer CLI tool | Enable | Enable |
| Containerised web service | Disable | Disable |
| Library / embedded SDK | Disable | Disable |
Feature flags are controlled via props.SetFeatures:
This mirrors the existing UpdateCmd, McpCmd, DoctorCmd pattern documented in CLAUDE.md.
Relationship with init and Initialisers¶
These two systems serve different audiences and workflows:
init <subsystem> |
config get/set/list |
|
|---|---|---|
| Audience | Humans doing first-run or targeted reconfiguration | CI pipelines, shell scripts, tool authors |
| Interaction | Interactive TUI with per-subsystem guided forms | Non-interactive; reads/writes single keys |
| Scope | A whole subsystem (e.g. all AI keys at once) | Any individual key in dot-notation |
| Discovery | Registered via setup.Register() in init() |
Operates on the live Viper config directly |
| Use case | "I want to change my AI provider" | "Set github.url.api to a GHE URL in CI" |
Non-goal: config does not replace or duplicate init <subsystem>. Humans who want to interactively reconfigure a subsystem should use init <subsystem>, which provides the structured guided experience for that area. config set is for scripted key-by-key access.
Design Decisions¶
Dot-notation key access¶
Viper already supports dot-notation for nested keys (e.g. github.token). The get and set subcommands expose this directly, keeping the mental model consistent with how keys appear in YAML and in code via viper.GetString("github.token").
Sensitive value masking¶
Masking uses two independent detection strategies applied in combination โ a value is masked if either triggers:
-
Key name patterns โ the dot-notation key's final segment (leaf name) matches a known sensitive substring:
token,password,secret,key,apikey,auth(case-insensitive). This catches keys likeai.claude.keyorgithub.ssh.key.path. -
Value content patterns โ the value itself matches a known credential regular expression, regardless of the key name. This catches cases where a sensitive value is stored under a non-obvious key such as
github.auth.value. Built-in patterns cover common credential formats (e.g. GitHub PATs:ghp_[A-Za-z0-9]{36},github_pat_[A-Za-z0-9_]{82}).
Both the key-name list and the value-content regexes are extensible: tool authors can register additional patterns via a Masker type using a functional options pattern, rather than modifying the defaults. This allows tools built on GTB to mask their own credential formats without forking the masking logic.
The masking strategy itself reuses the approach from pkg/setup/ai/ai.go: display only the last 4 characters of the detected secret, replacing the rest with asterisks (or full asterisks if the value is 4 characters or fewer). An --unmask flag on get bypasses all masking.
Output formats¶
get and list support --output text|json|yaml (default: text) to enable CI and shell script consumption without text parsing. JSON output is particularly useful for piping into tools like jq.
Config writes via Viper¶
All write operations go through viper.Set() followed by viper.WriteConfig(). This preserves Viper as the single source of truth and ensures writes respect the active config file path. If no config file exists, viper.SafeWriteConfig() is used to create one.
Schema validation¶
The config validate subcommand checks the current configuration against required key definitions already present in validateConfig within root.go. Validation reports missing required fields, type mismatches, and values that fail format checks (e.g. URLs). Output is a list of diagnostics with severity levels (error, warning).
Public API Changes¶
New package: pkg/cmd/config/¶
// NewCmdConfig returns the top-level config command with all subcommands attached.
// MaskerOptions extend the built-in sensitive key and value patterns.
func NewCmdConfig(props *props.Props, opts ...MaskerOption) *cobra.Command
// NewCmdGet returns the "config get <key>" subcommand.
func NewCmdGet(props *props.Props) *cobra.Command
// NewCmdSet returns the "config set <key> <value>" subcommand.
func NewCmdSet(props *props.Props) *cobra.Command
// NewCmdList returns the "config list" subcommand.
func NewCmdList(props *props.Props) *cobra.Command
// NewCmdValidate returns the "config validate" subcommand.
func NewCmdValidate(props *props.Props) *cobra.Command
Feature flag addition¶
Sensitive masking (pkg/cmd/config)¶
// Masker detects and masks sensitive config values. The zero value is not
// useful; use NewMasker to construct one with defaults.
type Masker struct { /* unexported */ }
// MaskerOption configures a Masker.
type MaskerOption func(*Masker)
// WithKeyPattern registers an additional key-name substring (case-insensitive)
// that marks a key as sensitive. Extends the built-in list; does not replace it.
func WithKeyPattern(pattern string) MaskerOption
// WithValuePattern registers an additional compiled regexp that, when matched
// against a value, marks it as sensitive regardless of the key name.
func WithValuePattern(re *regexp.Regexp) MaskerOption
// NewMasker constructs a Masker with built-in key patterns and value regexes,
// extended by any provided options.
func NewMasker(opts ...MaskerOption) *Masker
// IsSensitive returns true if the key name matches a sensitive key pattern
// OR the value matches a sensitive value pattern.
func (m *Masker) IsSensitive(key, value string) bool
// Mask returns the value with all but the last 4 characters replaced by
// asterisks. Returns the full asterisk string if the value is 4 characters
// or fewer.
func (m *Masker) Mask(value string) string
// MaskIfSensitive applies Mask only when IsSensitive returns true.
func (m *Masker) MaskIfSensitive(key, value string) string
Built-in key patterns: token, password, secret, key, apikey, auth.
Built-in value patterns: GitHub classic PAT (ghp_[A-Za-z0-9]{36}), GitHub fine-grained PAT (github_pat_[A-Za-z0-9_]{82}).
The Masker is constructed once at command initialisation and threaded through the get, list, and validate subcommands. The root NewCmdConfig accepts ...MaskerOption so tool authors can extend the defaults at the point where they wire up the command.
Internal Implementation¶
config get¶
- Accept a single positional argument: the dot-notation key.
- Read the value via
props.Config(theconfig.Containableinterface). - If the key does not exist in Viper, return an error using
cockroachdb/errors. - Unless
--unmaskflag is set, callmasker.MaskIfSensitive(key, value)โ this applies masking if either the key name or the value content matches a sensitive pattern. - Render according to
--outputflag:textprints the raw value,jsonwraps in{"key": "...", "value": "..."},yamlrenders askey: value.
config set¶
- Accept two positional arguments: key and value.
- Attempt type coercion: if the value parses as bool or int, store the typed value; otherwise store as string.
- Call
viper.Set(key, value). - Write config via
viper.WriteConfig(). If no config file exists, useviper.SafeWriteConfig(). - Print confirmation message.
config list¶
- Retrieve all settings via
viper.AllSettings(). - Flatten the nested map into dot-notation keys.
- Sort keys alphabetically.
- For each key, call
masker.MaskIfSensitive(key, value)โ masking triggers on either the key name or the value content matching a sensitive pattern. - Render according to
--outputflag:textrenders a formatted two-column table (key, value) using lipgloss styling;jsonrenders a flat JSON object;yamlrenders the full nested YAML structure.
config validate¶
- Load validation rules from the existing
validateConfiglogic inroot.go. Extract this into a shared, testable function if not already. - Iterate over rules, checking each against current config values.
- Collect diagnostics:
{Key, Severity, Message}. - Render diagnostics as a table or list. Exit with non-zero status if any errors are found.
Project Structure¶
pkg/cmd/config/
config.go # NewCmdConfig, parent command setup
get.go # NewCmdGet implementation
get_test.go
set.go # NewCmdSet implementation
set_test.go
list.go # NewCmdList implementation
list_test.go
validate.go # NewCmdValidate implementation
validate_test.go
sensitive.go # MaskSensitive, IsSensitiveKey helpers
sensitive_test.go
Testing Strategy¶
Unit Tests¶
Maskertested with table-driven tests covering: key-name pattern matching (includinggithub.auth.valueโ not masked by key, but masked by value regex), built-in GitHub PAT value patterns, custom patterns viaWithKeyPatternandWithValuePattern,Maskedge cases (empty string, exactly 4 chars, long values), and that default patterns are not replaced by custom ones.config settests use afero in-memory filesystem to verify config file writes without touching disk.config gettests set up Viper with known values and assert correct output, including masking behaviour and all three--outputformats.config listtests verify alphabetical ordering, masking of sensitive keys, and all three--outputformats.config validatetests provide configs with missing keys, wrong types, and valid configs to assert correct diagnostic output.- Mocks generated via mockery/v3 for
config.Containableand any other interfaces. - Coverage target: 90%+ for all files in
pkg/cmd/config/.
Integration Tests¶
- Config file round-trip: Write values via
config set, read back viaconfig get, verify consistency across the Viper config layer and on-disk YAML. - Schema validation end-to-end: Load a multi-file config with embedded defaults, run
config validate, assert correct diagnostics for missing required keys and type mismatches. - Gate with
testutil.SkipIfNotIntegration(t, "config")in a dedicatedconfig_integration_test.gofile.
E2E BDD Tests (Godog) โ Strong fit¶
The config subcommand introduces four user-facing CLI operations with clear Given/When/Then semantics. Feature file: features/cli/config.feature.
@cli @smoke
Feature: CLI Config Command
Background:
Given the gtb binary is built
And a temporary init directory
Scenario: Get a config value
Given the init directory contains a config file:
"""
log:
level: debug
"""
When I run gtb with "config get log.level --config {init_dir}/config.yaml"
Then the exit code is 0
And stdout contains "debug"
Scenario: Set a config value
Given the init directory contains a config file:
"""
log:
level: info
"""
When I run gtb with "config set log.level debug --config {init_dir}/config.yaml"
Then the exit code is 0
And the config file in the init directory contains "level: debug"
Scenario: List config values with sensitive masking
Given the init directory contains a config file:
"""
github:
auth:
value: secret-token-123
log:
level: info
"""
When I run gtb with "config list --config {init_dir}/config.yaml"
Then the exit code is 0
And stdout contains "log.level"
And stdout does not contain "secret-token-123"
Scenario: Validate config reports missing keys
Given the init directory contains a config file:
"""
custom:
key: value
"""
When I run gtb with "config validate --config {init_dir}/config.yaml"
Then the exit code is not 0
Scenario: JSON output for CI consumption
Given the init directory contains a config file:
"""
log:
level: warn
"""
When I run gtb with "config get log.level --config {init_dir}/config.yaml --output json"
Then the exit code is 0
And stdout is valid JSON
Note: Once config get is implemented, it unblocks config precedence E2E testing (deferred from the Godog BDD strategy Phase 3). Add scenarios verifying file โ env โ flag precedence once the command is in place.
Backwards Compatibility¶
No breaking changes. The config subcommand is purely additive. Existing config file formats are unchanged. The feature flag defaults to disabled โ tools must explicitly opt in, ensuring tools that do not need local config management are unaffected.
Future Considerations¶
config editTUI: An interactive editor that builds ahuh.Formdynamically from all current config keys (grouped by section) could complementconfig setfor humans who prefer a guided form over key-by-key commands. This was deferred because theinit <subsystem>pattern already provides superior per-subsystem TUI forms; a flat combined editor adds limited value overinit <subsystem>+ manual YAML editing.- Config profiles: support multiple named config files (e.g.
--profile staging) for switching between environments. - Config diff: show differences between current config and defaults, or between two config files.
- Config export/import: export config as JSON/YAML for sharing, import from a file or stdin.
- Remote config: read/write config from remote sources (e.g. environment variables, Vault) through Viper's existing remote provider support.
Implementation Phases¶
Phase 1: Core read operations¶
- Implement
config getandconfig listsubcommands with--output text|json|yamlsupport. - Implement
MaskSensitiveandIsSensitiveKeyhelpers with full test coverage. - Register
ConfigCmdfeature flag (default: disabled). - Wire
configcommand into root command registration when flag is enabled.
Phase 2: Write operations and validation¶
- Implement
config setsubcommand with type coercion. - Extract validation rules from
root.gointo a shared function. - Implement
config validatesubcommand.
Verification¶
-
config get github.tokenreturns a masked value. -
config get github.token --unmaskreturns the full value. -
config get nonexistent.keyreturns a clear error message. -
config get log.level --output jsonreturns valid JSON. -
config set github.token <value>writes to the config file and is readable viaconfig get. -
config listdisplays all keys alphabetically with sensitive values masked. -
config list --output jsonreturns a valid JSON object of all keys. -
config validatereports missing required fields and type mismatches. -
config validateexits 0 when config is valid, non-zero otherwise. - All tests pass:
just test-pkg pkg/cmd/config. - Coverage is 90%+ for
pkg/cmd/config/. - Feature flag
ConfigCmd: false(default) prevents the command from registering. - Feature flag
ConfigCmd: trueregisters the command alongsideinitin a CLI tool.