store-editor

SkillDev tools

Use 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.

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: true to remove fields the user shouldn't see (complex nested types like map[string]string, []struct)
  • Use prefix-based hiding: hiding path "build.instructions" also hides "build.instructions.env", "build.instructions.root_run", etc.
  • Set ReadOnly for fields managed by other systems (e.g., host_proxy.* ports)
  • Set Kind + Options for constrained fields (e.g., workspace.default_mode → KindSelect with ["bind", "snapshot"])
  • Set Label and Description for human-friendly display text
  • Set Order to control sort position within tabs (lower = first)

LayerTarget patterns:

  • BuildLayerTargets(store) derives all targets from store.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-reported Filename; domain adapters relabel filenames they recognize (the project adapter labels clawker.local.yaml layers storeui.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 from internal/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:

  1. Load config via opts.Config()
  2. Get the store: cfg.FooStore() (or cfg.SettingsStore(), cfg.ProjectStore())
  3. Call domain adapter's Edit(ios, store, cfg)
  4. 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:

  1. Coerce the TUI string into the field's typed value via a fresh T: SetFieldValue(&fresh, fieldPath, value) then GetFieldValue(&fresh, fieldPath)
  2. Stage it: store.Set(fieldKey(fieldPath), typed) — fieldKey splits the dotted schema path into segments. Set is 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 to Remove instead — Set(key, nil) is ErrNilValue by design.
  3. 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 TypeFieldKindEditor
stringKindTextTextareaEditorModel
boolKindBoolSelectField (true/false)
*boolKindBoolSelectField (nil → false display)
int, int64KindIntTextField
[]stringKindStringSliceListEditorModel
time.DurationKindDurationTextField
map[string]stringKindMapKVEditorModel
[]structKindStructSliceTextareaEditorModel (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: true removes the field (exact match + prefix-based for hiding entire subtrees)
  • Unrecognized FieldKind values map to BrowserStructSlice (read-only) in fieldKindToBrowserKind
  • 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

MethodPurpose
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

  • WalkFields and SetFieldValue panic on nil or non-struct input — these are programming errors
  • ApplyOverrides panics on duplicate override paths — catch in tests
  • []string fields use comma-separated format — entries containing commas will break the parser
  • time.Duration uses time.ParseDuration — accepts 5m30s, 1h, 300ms (standard Go duration)
  • *bool fields: nil is treated as false for display; SetFieldValue allocates a non-nil pointer
  • Unrecognized FieldKind values (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 during SetFieldValue
  • 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
store-editor: Skill · ahel