config edit / config path / config unset β rounding out the config command surface (B3)¶
- Authors
- Matt Cockayne
- Date
- 21 June 2026
- Status
- IMPLEMENTED (2026-06-21)
Summary¶
The config command group (pkg/cmd/config/) today exposes get, list,
set, validate, and migrate-credentials β a read/write surface aimed at CI
automation and scripted setup rather than interactive editing
(pkg/cmd/config/config.go). Three obvious operations are still missing next to
those subcommands:
config editβ open the active config file in the user's editor ($VISUALβ$EDITORβ platform fallback), let them hand-edit it, then re-validate the result and persist only if it is valid, aborting and leaving the original file untouched on invalid input or a non-zero editor exit.config pathβ print the resolved config file path(s). Because GTB merges config across a precedence chain (CLI flags > env > file > embedded assets > defaults), this prints the ordered list of files that contributed to the live config, plus the writable target thatset/unset/editmutate.config unset <key>β remove a single dot-notation key from the user's config file (the writable layer only), the inverse ofconfig set.
This is purely gap-filling alongside existing subcommands; see Decision Log. Nothing is rejected and there is no conflict with the current command surface.
Motivation¶
config set <key> <value> exists, but there is no config unset to undo it β
users must hand-edit YAML or delete the whole file. There is no way to ask the
tool where its config actually lives (a frequent support question, made
non-trivial by the multi-file merge precedence and the difference between
"files that were read" and "the file a write lands in"). And there is no
ergonomic "just let me edit the file safely" path: today a user editing the file
by hand gets no validation until the next command run, and a typo silently
breaks the tool or β under hot-reload (pkg/config Observable/watcher) β is
rejected fail-closed with only a log line.
All three are small, self-contained, and reuse machinery that already exists in
pkg/cmd/config/ and pkg/config/. They round out the command to parity with
the config CLIs of comparable tools (gh config, git config).
Scope¶
ConfigCmd is default-disabled (pkg/props/tool.go:
isDefaultEnabled lists ConfigCmd under the false branch; it is absent from
DefaultFeatures). These subcommands inherit that gating automatically β a tool
author opts in with props.Enable(props.ConfigCmd). No feature-flag change is
required and none is made.
In scope¶
- Three new subcommands registered in
NewCmdConfig(pkg/cmd/config/config.go). $VISUAL/$EDITORresolution with a platform fallback, surfaced as a small reusable helper.- A read-modify-write round-trip for
editover theafero.Fsinprops.FS, re-usingwriteConfigAtomicand the existingresolveWritableConfigPath. - Re-validation of the edited / post-unset config against the base schema
(
buildBaseSchemainvalidate.go) before persisting. - A new public accessor on
config.Containerto expose the ordered list of contributing config files forconfig path(see API Surface). - Unit tests (β₯90% for any new
pkg/code) and Gherkin E2E scenarios (config editandconfig unsetare user-facing CLI workflows).
Out of scope¶
- Encrypting or migrating config (covered by
migrate-credentials). - Editing env-var or flag-sourced values (those layers are not file-backed;
unset/editoperate on the file layer only β documented explicitly). - A TUI form editor (
pkg/forms) βeditshells out to the user's editor by design. - Changing the merge-precedence model or the writable-path resolution rules.
Design¶
Subcommand: config path¶
- Default: prints every file that contributed to the live config, in merge
order, one per line, followed by the writable target. Mirrors the
output.IsJSONOutput/output.Emitpattern used byconfig get/listso the structured forms are scriptable. --writable: prints only the single path thatset/unset/editwrite to (the output ofresolveWritableConfigPath), for use in scripts.- Resolution:
- The ordered contributing files come from a new
Container.ConfigFiles() []stringaccessor (the privateconfigFilesslice is otherwise unreachable;GetViper().ConfigFileUsed()returns only the last file, which is insufficient for a multi-file merge). See API Surface. - The writable target is
resolveWritableConfigPath(props, fs)(already inset.go), which returnsviper.ConfigFileUsed()when bound, elsefilepath.Join(setup.GetDefaultConfigDir(...), setup.DefaultConfigFilename). - When no file is loaded (embedded-only config),
pathprints just the writable target (the file a futuresetwould create) and notes that no file is currently loaded β it does not error.
Subcommand: config unset <key>¶
Args: cobra.ExactArgs(1); key is dot-notation, matchingset/get.- Errors with
config key %q not found(matchingget's message) if the key is not present in the file layer β checked viaContainer.Has(key)(ViperInConfig, which inspects only file-sourced keys, exactly the layerunsetcan affect). This avoids the surprise of "unset succeeded" for a key that only ever came from an env var or flag and is therefore untouched. - Mechanics mirror
persistConfigValueinset.go, substituting the delete: fs := writableFS(props);path := resolveWritableConfigPath(props, fs).- Read +
yaml.Unmarshalthe existing file intomap[string]any. deleteNestedKey(settings, key)(already inmigrate.go).- Re-validate the resulting settings against
buildBaseSchema()before writing β removing a required key (e.g.log.level) must be refused, not silently persisted. On invalid result, abort with theprintValidationResultoutput and a non-zero exit; the file is untouched. yaml.Marshal+writeConfigAtomic(fs, path, data)(0600, temp-file + rename).- Reload so subsequent
props.Configreads reflect the write (sameReadInConfigstepmigrate.goperforms after its rewrite). - Prints
unset %s\non success; honoursoutput.Emitfor--output json.
Subcommand: config edit¶
The round-trip, all over props.FS (afero.Fs) so it is testable without a
real terminal or editor:
- Resolve the writable path via
resolveWritableConfigPath. If the file does not yet exist, seed the temp file with the current effective config (so the user edits a populated file, not a blank one) β or, if nothing is loaded, an empty document with a header comment. - Resolve the editor:
--editorflag β$VISUALβ$EDITORβ platform fallback (vion unix,notepadon windows). Surfaced asresolveEditor(flag string) (string, error)(see [API Surface]). - Require an interactive terminal: if
utils.IsInteractive()is false (piped/CI), refuse with a clear error pointing atconfig set/unsetfor non-interactive use. Literal-editor invocation in CI is a footgun. - Temp-file round-trip:
- Write the current file contents (or seed) to a temp path on
props.FS(sibling of the target,*.edit.tmp, mode 0600). - Launch the editor against the temp path and wait. The subprocess
invocation is intentional dev-tooling exec and must carry a narrowly
scoped
//nolint:gosec // G204: operator-selected editor on an operator-named temp fileper the project's suppression policy. Editor argv is split on whitespace to allow$EDITOR="code --wait". - On non-zero editor exit, abort: discard the temp file, leave the original untouched.
- Re-validate before persist:
- Read the edited temp file;
yaml.Unmarshalto detect syntax errors (abort with a parse error, keep the temp file path in the message so the user can recover their edit). - Build a throwaway validation view and run it against
buildBaseSchema(). On invalid, printprintValidationResult, abort, and tell the user their unsaved edit is preserved at the temp path. - Persist atomically:
writeConfigAtomic(fs, path, editedData), then reload (ReadInConfig) so the liveprops.Configreflects the edit. Print a confirmation; no-op message if the content was unchanged.
Note on
props.FSvs. the real editor: the editor subprocess edits a real OS path. Whenprops.FSis the defaultafero.NewOsFs()this is the same file. Tests inject a memmap FS and a fake editor (a deterministic mutator function) through the same seam used elsewhere β the editor is invoked via an injectableeditorRunner func(ctx, editorCmd, path) errorfield on the command constructor, not a package-levelvar(the project bans package-level exec hooks fort.Parallel()race-safety; see CLAUDE.md Testing).internal/exectestprovides the real-exec wiring.
Validation reuse¶
All three persist paths route through the same schema check as
config validate: buildBaseSchema() β props.Config.Validate(schema) β
ValidationResult.Valid() / printValidationResult. This keeps a single
source of truth for "what a valid config is" and means edit/unset can never
write a config that validate would reject.
Public API surface¶
New exported symbol in pkg/config (the only library change; the rest lives in
the command package pkg/cmd/config, which is not part of the stable surface):
// ConfigFiles returns the ordered list of config files that contributed to
// this container's live configuration, in merge order (lowest to highest
// precedence among file sources). Returns an empty slice for reader/embedded
// containers that were not loaded from any file. The slice is a copy; callers
// may not mutate the container's internal state through it.
func (c *Container) ConfigFiles() []string
- Added to
pkg/config/container.gonext to the existing accessors; backed by the existing privateconfigFiles []stringfield (copied underc.mu, same discipline asreload'sappend([]string(nil), c.configFiles...)). - Added to the
Containableinterface incontainer.goso the command can depend on the interface, not the concrete type. This is a breaking change toContainable(a new method) β permitted pre-1.0 as a minor bump per the API-stability policy inCLAUDE.md; note it in the commit body and add adocs/migration/note. Any downstream implementingContainabledirectly (rare; most embed*Container) must add the method. The project'smocks/forContainableare regenerated viajust mocks.
Command constructors (package-private, mirroring existing ones):
func NewCmdPath(props *p.Props) *cobra.Command
func NewCmdUnset(props *p.Props) *cobra.Command
func NewCmdEdit(props *p.Props, opts ...EditOption) *cobra.Command // EditOption injects the editorRunner for tests
All three are registered in NewCmdConfig via setup.Wrap(p.ConfigCmd, β¦),
matching the existing registration block.
Editor helper (package-private to pkg/cmd/config, or internal/ if reused):
Error handling¶
- All errors use
github.com/cockroachdb/errors(project standard), withWithHintwhere a user action is implied (e.g. "set $EDITOR or pass --editor", "runconfig validateto see what's wrong"). edit/unsetare fail-closed on validation: an invalid result aborts and the original file is never overwritten. The atomic temp-file+rename inwriteConfigAtomicguarantees no torn write even if persistence itself is interrupted.editpreserves the user's work: on parse/validation failure the temp file is retained and its path reported, so edits aren't lost.
Testing strategy¶
Unit (table-driven, t.Parallel(), logger.NewNoop(), memmap afero.Fs):
config path: single file, multi-file merge, no-file (embedded-only),--writable, and all three--outputformats.config unset: present file key (removed + file rewritten), key absent from file but present via env (refused with not-found), required-key removal refused by validation, JSON output, nested key.config edit: happy path (fake editor mutates temp β validates β persisted + reloaded), editor non-zero exit (abort, original intact), invalid YAML after edit (abort, temp retained), schema-invalid after edit (abort, temp retained), non-interactive refusal,--editoroverride,$VISUALover$EDITORprecedence, unchanged-content no-op.ConfigFiles(): copy semantics (mutating the returned slice doesn't affect the container), empty for embedded containers, order preserved.
E2E (Godog, features/, gated INT_TEST_E2E_CLI=1): a config-surface.feature
with scenarios for config path output and config unset round-trip using the
cmd/e2e/ test binary (all features enabled). config edit uses a scripted
fake editor via an env-var-named command so the scenario is deterministic.
Documentation¶
- Update
docs/components/config command reference with the three new subcommands and the file-layer-only caveat forunset/edit. - Add a
docs/migration/note for theContainable.ConfigFiles()addition. - Cross-reference the validation reuse and the writable-path resolution rules.
Implementation plan¶
- Add
Container.ConfigFiles()+Containablemethod; regenerate mocks; migration note. (pkg/config) config pathsubcommand + tests.config unsetsubcommand + tests (reusedeleteNestedKey,resolveWritableConfigPath,writeConfigAtomic,buildBaseSchema).resolveEditorhelper +config editsubcommand with injectableeditorRunner+ tests.- Register all three in
NewCmdConfig. - Gherkin scenarios + step defs.
/gtb-verify, docs,/simplify.
Open questions¶
config pathdefault output β list all contributing files plus the writable target, or only the writable target by default (with--allto expand)? This spec proposes "all files + writable target" as the default because the multi-file merge is exactly what makes the path non-obvious. Confirm.config editseeding β when the target file does not yet exist, seed the temp file with the full effective config (all merged values, including embedded defaults) or just an empty/commented skeleton? Seeding with the full effective config risks materialising embedded defaults into the user file (the very thingpersistConfigValueis careful to avoid). Proposed: seed with an empty document + a header comment, soeditnever silently bakes defaults into the file. Confirm.- Editor argv splitting β naive whitespace split handles
EDITOR="code --wait"but not quoted paths with spaces. Acceptable for v1, or useshellwords-style parsing? Proposed: whitespace split for v1, documented. ConfigFiles()onContainableβ add to the interface (breaking, but clean) or expose only on the concrete*Containerand have the command type-assert? Proposed: add to the interface (pre-1.0 minor break is acceptable and keeps the command interface-clean). Confirm.
Resolutions (open questions confirmed with user 2026-06-21)¶
config pathdefault output β RESOLVED: print all contributing files (in merge order) plus the writable target by default. The multi-file merge is what makes the resolved path non-obvious, so the full picture is the useful default.config editseeding β RESOLVED: seed a not-yet-existing target with an empty document + header comment, never the full effective config.editmust not silently materialise embedded defaults into the user file.- Editor argv splitting β RESOLVED: handle both β use shellwords-style
(shell-quoting-aware) parsing so it covers both
EDITOR="code --wait"and quoted paths containing spaces. This supersedes the draft's "whitespace split for v1" proposal; the design/testing sections should specify shell-quoting- aware parsing (table-driven cases: unquoted flags, quoted paths with spaces, mixed). ConfigFiles()onContainableβ RESOLVED: add it to the interface. The pre-1.0 minor break is acceptable; regenerate mocks and add a migration note. Keeps the command interface-typed.
Decision Log¶
| Decision | Status | Rationale |
|---|---|---|
Add config edit, config path, config unset |
Accepted | Obvious gaps next to existing get/set/list/validate/migrate-credentials; no conflict with current surface. |
Reuse writeConfigAtomic / setNestedKey / deleteNestedKey / resolveWritableConfigPath / buildBaseSchema |
Accepted | Single source of truth for file writes + validation; no new write path. |
unset/edit operate on the file layer only |
Accepted | Env/flag layers are not file-backed; mutating them is out of scope and would be surprising. |
config edit requires an interactive TTY (utils.IsInteractive) |
Accepted | Shelling to $EDITOR in CI is a hang/footgun; set/unset cover scripted use. |
Editor invocation via injectable runner, not a package-level var |
Accepted | Project bans package-level exec hooks for t.Parallel() race-safety (CLAUDE.md). |
Add Container.ConfigFiles() to Containable (breaking) |
Accepted (pre-1.0 minor) | Needed for multi-file config path; ConfigFileUsed() only returns the last file. |
| Nothing rejected | β | No conflicting or superseded design considered; this is gap-filling. |