store-editor
SkillDev toolsUse when adding or changing a store editor: internal/storeui adapters, settings or config edit commands, field browsers, and their tests.
Available today. Use it from your connected AI after setup.
No other account needed.
Add ahel to your AI once: Claude, ChatGPT, Cursor, Claude Code or Codex. Then ask it to use this.
Then ask your AI: use the store-editor skill
What this skill tells your AI
The instructions your AI receives, as published by schmitthub/clawker in .agents/skills/store-editor/SKILL.md and read by ahel’s review.
Package contract and editor checklist: internal/storeui/AGENTS.md. This skill holds the full architecture, data flow, test patterns, and gotchas.
Architecture Overview
Store UI is the system for building interactive TUI editors for any storage.Store[T] instance. It has four layers:
Command layer (cmd/settings/edit, cmd/project/edit)
→ Domain adapter (config/storeui/settings, config/storeui/project)
→ Orchestration (internal/storeui)
→ Presentation (internal/tui — FieldBrowserModel, ListEditorModel, TextareaEditorModel)
→ Persistence (internal/storage — Store[T])
Import boundary: storeui does NOT import bubbletea or bubbles. All presentation is delegated to internal/tui via generic types (BrowserField, BrowserConfig, etc.). The edit.go file maps storeui.FieldKind → tui.BrowserFieldKind to keep the abstraction boundary clean.
How to Build a New Store UI
Step 1: Domain Adapter
Create a package under internal/config/storeui/<domain>/ that exports:
// Overrides customizes reflected fields for interactive editing. Takes a
// config.Config only when the override set is config-derived (the project
// adapter needs it; the settings adapter does not).
func Overrides() []storeui.Override
// LayerTargets builds save destinations from the store's own write targets.
func LayerTargets(store *storage.Store[T]) ([]storeui.LayerTarget, error)
// Edit is the convenience entry point wiring overrides + targets.
func Edit(ios *iostreams.IOStreams, store *storage.Store[T]) (storeui.Result, error)
Override patterns:
- Set
Hidden: trueto remove fields the user shouldn't see (complex nested types likemap[string]string,[]struct) - Use prefix-based hiding: hiding path
"build.instructions"also hides"build.instructions.env","build.instructions.root_run", etc. - Set
ReadOnlyfor fields managed by other systems (e.g.,host_proxy.*ports) - Set
Kind+Optionsfor constrained fields (e.g.,workspace.default_mode→KindSelectwith["bind", "snapshot"]) - Set
LabelandDescriptionfor human-friendly display text - Set
Orderto control sort position within tabs (lower = first)
LayerTarget patterns:
BuildLayerTargets(store)derives all targets fromstore.WriteTargets(): the walk-up target (the in-play walk-up layer for the write filename, or the CWD dual-placement candidate when none is discovered) is labeled "Project", configured-directory candidates "User", and discovered layers use their shortened path as label. Each target carries the store-reportedFilename; domain adapters relabel filenames they recognize (the project adapter labelsclawker.local.yamllayersstoreui.LabelLocal)- A store without walk-up (e.g. settings) gets no "Project" target — it could never rediscover a CWD file, so offering one would silently lose the saved value
- Use
ShortenHome()for the Description field (exported frominternal/storeui)
Step 2: Command Integration
Create a Cobra command under internal/cmd/<noun>/edit/:
type EditOptions struct {
IOStreams *iostreams.IOStreams
Config func() (config.Config, error)
}
func NewCmdSettingsEdit(f *cmdutil.Factory, runF func(context.Context, *EditOptions) error) *cobra.Command
// or for project:
func NewCmdProjectEdit(f *cmdutil.Factory, runF func(context.Context, *EditOptions) error) *cobra.Command
The run function:
- Load config via
opts.Config() - Get the store:
cfg.FooStore()(orcfg.SettingsStore(),cfg.ProjectStore()) - Call domain adapter's
Edit(ios, store, cfg) - Handle result: print success/cancel message
Step 3: Wire into Parent Command
Add edit.NewCmdSettingsEdit(f, nil) (or the noun-appropriate constructor) to the parent command's AddCommand list.
Orchestration Layer (internal/storeui)
Data Flow
Edit[T storage.Schema](ios, store, opts...):
1. schemaFields[T](store) → []Field: T.Fields() metadata (path/label/desc/kind/default/required)
+ one storage.Get[V] per declared leaf for the current merged value
(ErrKeyNotFound = unset → value blank, default shown)
2. ApplyOverrides(fields, overrides) → filtered + customized fields (TUI-specific only: Hidden, ReadOnly, Kind, Options)
3. Map to tui types: fieldsToBrowserFields(), layersToBrowserLayers()
4. Wire OnFieldSaved, OnFieldDeleted, and OnRefresh callbacks
5. tui.NewFieldBrowser(cfg) → tui.RunProgram()
6. Return Result{Saved, Cancelled, SavedCount}
There is no whole-struct snapshot anywhere in the flow — each field's value is decoded individually from the merged tree.
Per-Field Save Flow
When a user edits a field and picks a save target:
- Coerce the TUI string into the field's typed value via a fresh
T:SetFieldValue(&fresh, fieldPath, value)thenGetFieldValue(&fresh, fieldPath) - Stage it:
store.Set(fieldKey(fieldPath), typed)—fieldKeysplits the dotted schema path into segments.Setis unconditionally dirty, so saving to a non-provenance-winner layer needs no force-dirty step. An editor that produced no value (cleared map/struct) routes toRemoveinstead —Set(key, nil)isErrNilValueby design. store.WriteFieldTo(target.Path, fieldKey(fieldPath)...)— persist exactly this field to the chosen layer file; other staged fields stay staged.
WriteFieldTo internally remerges layers, so re-read values reflect the true merged state after each save. Deletes go through store.Remove(key...), tolerating ErrKeyNotFound on an already-unset row.
Field Discovery (WalkFields)
Reflection-based struct walker. Type mapping:
| Go Type | FieldKind | Editor |
|---|---|---|
string | KindText | TextareaEditorModel |
bool | KindBool | SelectField (true/false) |
*bool | KindBool | SelectField (nil → false display) |
int, int64 | KindInt | TextField |
[]string | KindStringSlice | ListEditorModel |
time.Duration | KindDuration | TextField |
map[string]string | KindMap | KVEditorModel |
[]struct | KindStructSlice | TextareaEditorModel (raw YAML) |
struct | (recursed) | — |
*struct | (recursed, nil → zero value) | — |
| consumer-defined kind | (via KindFunc) | Read-only (enforced by fieldsToBrowserFields) |
| unrecognized type | — | Falls back to KindStructSlice (enrichWithSchema overwrites kind from schema) |
Uses yaml struct tags for field naming. Falls back to lowercase field name.
Extension model: classifyAndFormat falls back to KindStructSlice for unrecognized types — this is expected when consumers register custom kinds via KindFunc. enrichWithSchema overwrites the kind from the authoritative schema metadata afterward. fieldKindToBrowserKind maps unrecognized FieldKind values to BrowserStructSlice, and fieldsToBrowserFields forces ReadOnly = true for consumer-defined kinds (> KindLast) to prevent data corruption via the raw textarea editor.
Reverse Reflection (SetFieldValue)
Sets a field on a struct pointer by dotted YAML path ("build.image" → Build.Image). Allocates nil *struct parents as it walks. Panics on non-pointer input.
Override Merging (ApplyOverrides)
- Non-nil override pointer fields replace original values
Hidden: trueremoves the field (exact match + prefix-based for hiding entire subtrees)- Unrecognized
FieldKindvalues map toBrowserStructSlice(read-only) infieldKindToBrowserKind - Result sorted by
Order(stable sort) - Panics on duplicate override paths
TUI Components
FieldBrowserModel (tui/fieldbrowser.go)
Domain-agnostic tabbed field browser. States: Browse → Edit → PickLayer → PickLayerDelete.
Configuration: BrowserConfig with Title, Fields []BrowserField, LayerTargets []BrowserLayerTarget, Layers []BrowserLayer, OnFieldSaved func(path, value string, targetIdx int) error, OnFieldDeleted func(fieldPath string, targetIdx int) error, OnRefresh func() (fields []BrowserField, layers []BrowserLayer)
Features:
- Fields grouped into tabs by top-level path key (e.g., "build", "security")
- Sub-section headings for 3+ segment paths
- Per-layer value breakdown when browsing (shows which layers define a value)
- Modified field tracking with count display
- Scroll management with auto-scroll to selection
Key bindings: ←/→ tabs, ↑/↓ navigate, Enter edit, Esc/q/Ctrl+C quit
ListEditorModel (tui/listeditor.go)
Manages []string fields. Parses comma-separated input into items.
Constructor: NewListEditor(label, value string, opts ...ListEditorOption)
Options: WithListValidator(fn func(string) error) — external validator run on confirm
Result: Value() string (comma-separated), IsConfirmed(), IsCancelled(), Err() string
Key bindings: a add, e edit, d/backspace delete, Enter confirm list, Esc cancel
TextareaEditorModel (tui/textareaeditor.go)
Multiline text editor wrapping bubbles/textarea.
Constructor: NewTextareaEditor(label, value string, opts ...TextareaEditorOption) — auto-sizes height from content
Options: WithTextareaValidator(fn func(string) error) — external validator run on save (Ctrl+S)
Result: Value() string, IsConfirmed(), IsCancelled(), Err() string
Key bindings: Ctrl+S save, Esc cancel
Storage API Used by Store UI
| Method | Purpose |
|---|---|
storage.Get[V](store, key...) | Decode one field's merged value into V; ErrKeyNotFound = unset |
store.Keys(key...) | Child key names (existence/enumeration) |
store.Set(key []string, value) | Stage an in-memory field by segment key |
store.Remove(key...) | Delete a key (the unset verb) |
store.WriteFieldTo(path, key...) | Persist one dirty field to an explicit layer file |
store.Layers() | All discovered layers (for layer breakdown display) |
store.WriteTargets() | Candidate save locations derived from options + layers (for LayerTargets) |
store.ProvenanceMap() | Display-form field keys → source file paths; drives the per-field source column (exact match, then parent path walk-up) |
Testing Patterns
Unit Testing Overrides
Every domain adapter should test that override paths match real struct fields:
func TestOverrides_AllPathsMatchFields(t *testing.T) {
fields := storeui.WalkFields(config.MySchema{})
fieldPaths := make(map[string]bool, len(fields))
for _, f := range fields {
fieldPaths[f.Path] = true
}
for _, ov := range Overrides() {
assert.True(t, fieldPaths[ov.Path],
"override path %q does not match any field", ov.Path)
}
}
Also test for duplicate override paths and verify specific override properties (e.g., read-only fields).
Round-Trip Integration Tests
Test the full edit pipeline: WalkFields → SetFieldValue → store.Set → store.Write → reload → verify:
func TestRoundTrip(t *testing.T) {
env := testenv.New(t)
store, dir := newTestStore[myStruct](t, env, initialYAML)
// Edit through the plumbing (coerce the string, then set by path)
var fresh myStruct
require.NoError(t, storeui.SetFieldValue(&fresh, "field.path", "new-value"))
typed, err := storeui.GetFieldValue(&fresh, "field.path")
require.NoError(t, err)
require.NoError(t, store.Set([]string{"field", "path"}, typed))
require.NoError(t, store.Write())
// Reload from disk — independent verification
reloaded := reloadStore[myStruct](t, dir)
got, err := storage.Get[string](reloaded, "field", "path")
require.NoError(t, err)
assert.Equal(t, "new-value", got)
}
Use testenv.New(t) for isolated XDG directories. Create stores with storage.New[T](...) + WithFilenames + WithPaths for filesystem-backed tests; storage.NewFromString[T](yaml) for in-memory fixtures.
Testing WalkFields
Verify walked fields match store reads and that field kinds are correct:
func TestWalkFields_PathsMatchSchema(t *testing.T) {
fields := storeui.WalkFields(myStruct{})
// Assert field count, paths, kinds against the schema struct
// (WalkFields reflects a value, not a store — the editor itself reads
// values per field via storage.Get)
}
Testing FieldBrowserModel
The FieldBrowserModel is a BubbleTea model — test via Init() + Update() + View():
func TestFieldBrowser_TabNavigation(t *testing.T) {
cfg := tui.BrowserConfig{
Title: "Test",
Fields: []tui.BrowserField{...},
}
m := tui.NewFieldBrowser(cfg)
m.Update(tea.KeyMsg{Type: tea.KeyRight}) // switch tab (pointer receiver mutates in-place)
view := m.View()
// Assert tab state, selected field, etc.
}
Testing ListEditorModel and TextareaEditorModel
func TestListEditor_AddItem(t *testing.T) {
m := tui.NewListEditor("packages", "git, curl")
// Send 'a' key to add, type new item, press Enter
m, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}})
// ... type and confirm
assert.Equal(t, "git, curl, newpkg", m.Value())
}
Gotchas
WalkFieldsandSetFieldValuepanic on nil or non-struct input — these are programming errorsApplyOverridespanics on duplicate override paths — catch in tests[]stringfields use comma-separated format — entries containing commas will break the parsertime.Durationusestime.ParseDuration— accepts5m30s,1h,300ms(standard Go duration)*boolfields: nil is treated asfalsefor display;SetFieldValueallocates a non-nil pointer- Unrecognized
FieldKindvalues (consumer-defined kinds) are enforced as read-only in the browser — no editor exists for them store.WriteFieldTo(path, key...)persists exactly one dirty field to the target layer file (WriteTo(path)sends all of them); type coercion happens duringSetFieldValue- Provenance display uses exact field match + parent path walk-up for nested fields
Signals
- GitHub stars
- 55
- Forks
- 6
- Last commit
- Sep 2026
Advanced
- Item type
- skill
- Key
store-editor- Source
- github.com/schmitthub/clawker