feat(interactive): add reflection-driven interactive prompt engine#249
feat(interactive): add reflection-driven interactive prompt engine#249ben-kalmus wants to merge 1 commit into
Conversation
Up to standards ✅🟢 Issues
|
| Metric | Results |
|---|---|
| Complexity | 236 |
| Duplication | 7 |
TIP This summary will be updated as you push new changes.
b28c05b to
c17e3a6
Compare
There was a problem hiding this comment.
Pull request overview
Introduces a new pkg/interactive package intended to build request bodies interactively by reflecting over Go structs and prompting the user for each field, enabling future --interactive command modes across the CLI.
Changes:
- Added a reflection-driven
Builderthat traverses structs (including optional pointers, unions, slices, maps, and “parameter bag” structs) and assigns values from prompts. - Added a
Prompterabstraction with a survey-backed implementation for production and a label-keyed scripted fake for stable unit tests. - Added reusable input validators and unit tests covering the builder, prompters, and classification helpers.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| pkg/interactive/validators.go | Adds reusable validator builders for interactive text input. |
| pkg/interactive/validators_test.go | Unit tests for the validator builders and user-facing messages. |
| pkg/interactive/scripted_prompter.go | Adds a label-keyed scripted Prompter fake for deterministic tests. |
| pkg/interactive/scripted_prompter_test.go | Tests matching/defaulting behavior of ScriptedPrompter. |
| pkg/interactive/prompter.go | Defines Prompter and implements SurveyPrompter via pkg/prompt + survey. |
| pkg/interactive/prompter_test.go | Tests that SurveyPrompter routes calls through prompt.SurveyAskOne. |
| pkg/interactive/classify.go | Reflection helpers for identifying unions, param-bags, required fields, etc. |
| pkg/interactive/classify_test.go | Unit tests for type/field classification helpers. |
| pkg/interactive/builder.go | Core reflection traversal + prompting and assignment logic. |
| pkg/interactive/builder_test.go | Unit tests covering scalar/composite traversal, enums, guards, and error propagation. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| func (s *SurveyPrompter) MultiSelect(label string, options []string) ([]int, error) { | ||
| var out []int | ||
| err := prompt.SurveyAskOne(&survey.MultiSelect{Message: label, Options: options}, &out, s.surveyOpts()...) | ||
| return out, err | ||
| } |
There was a problem hiding this comment.
Not a runtime problem here: survey v2.3.7 supports writing MultiSelect results into a *[]int. In core/write.go, the list-to-list copy converts each selected OptionAnswer to an int via its .Index field (the "OptionAnswer to an int" branch), so passing &[]int yields the selected 0-based indexes. Leaving MultiSelect returning []int as designed.
| case *survey.Select: | ||
| *(response.(*string)) = "picked" | ||
| case *survey.MultiSelect: | ||
| *(response.(*[]int)) = []int{1} | ||
| } |
There was a problem hiding this comment.
Tied to the comment above: SurveyPrompter.MultiSelect does pass a *[]int and survey populates it (OptionAnswer -> int by .Index), so the stub asserting *[]int is correct and will not panic.
| func (b *Builder) Build(v any) error { | ||
| rv := reflect.ValueOf(v) | ||
| if rv.Kind() != reflect.Pointer || rv.IsNil() || rv.Elem().Kind() != reflect.Struct { | ||
| return errors.New("interactive: Build needs a non-nil pointer to a struct") | ||
| } | ||
| return b.buildValue(rv.Elem().Type().Name(), rv.Elem(), 0, nil) | ||
| } |
| case reflect.String, reflect.Bool, reflect.Int32, reflect.Int64, reflect.Float32, reflect.Float64: | ||
| _, err := b.assignScalar(label, rv, false) | ||
| return err |
| case reflect.Int32, reflect.Int64: | ||
| hint := " (integer)" | ||
| if optional { | ||
| hint = " (integer, empty to skip)" | ||
| } | ||
| s, err := b.Prompter.Input(label+hint, intValidator(label, v.Type().Bits(), optional)) | ||
| if err != nil { | ||
| return false, err | ||
| } | ||
| if s == "" { | ||
| // Only reachable when optional: intValidator rejects empty for | ||
| // required fields, so the prompter re-prompts or errors before here. | ||
| return false, nil | ||
| } | ||
| n, perr := strconv.ParseInt(s, 10, v.Type().Bits()) | ||
| if perr != nil { | ||
| return false, fmt.Errorf("invalid integer for %s: %w", label, perr) | ||
| } | ||
| v.SetInt(n) | ||
| return true, nil | ||
| case reflect.Float32, reflect.Float64: |
| switch elem.Kind() { | ||
| case reflect.String, reflect.Bool, reflect.Int32, reflect.Int64, reflect.Float32, reflect.Float64: | ||
| // Optional scalar: build into a fresh element and only assign the pointer | ||
| // if the user provided a value. | ||
| ptr := reflect.New(elem) | ||
| set, err := b.assignScalar(label, ptr.Elem(), true) | ||
| if err != nil || !set { | ||
| return err | ||
| } | ||
| rv.Set(ptr) | ||
| return nil | ||
| } |
| f, perr := strconv.ParseFloat(s, 64) | ||
| if perr != nil { | ||
| return false, fmt.Errorf("invalid number for %s: %w", label, perr) | ||
| } | ||
| v.SetFloat(f) |
| func (b *Builder) inputInt(label string, bits int) (int64, error) { | ||
| s, err := b.Prompter.Input(label+" (integer)", intValidator(label, bits, true)) | ||
| if err != nil { | ||
| return 0, err | ||
| } | ||
| if s == "" { | ||
| return 0, nil | ||
| } | ||
| n, err := strconv.ParseInt(s, 10, bits) | ||
| if err != nil { | ||
| return 0, fmt.Errorf("invalid integer for %s: %w", label, err) | ||
| } | ||
| return n, nil | ||
| } |
A generic package that builds a request body by walking any struct via reflection and prompting for each field through a small Prompter interface (survey-backed in production, a scripted fake for tests). Handles scalars, optional pointers, enums (validated through the SDK's own IsValid method), oneOf unions, parameter bags, slices and maps, with per-field input validation and re-prompt on invalid entries. No SDK or command coupling.
c17e3a6 to
9a22ae5
Compare
Summary
Adds
pkg/interactive, a generic package that builds a request body by walking any Go struct with reflection and prompting for each field. It is the foundation for--interactivemodes across the CLI.This PR introduces the engine only. No command is wired to it yet (see the follow-up PR adding
--interactiveto compositions upsert).Demo
Stacked PRs
How it works
Builder.Build(&v)walks the struct and prompts for inputs: optional pointer fields can be skipped, large optional-only parameter objects let you multi-select which fields to fill.Enums (named string types the SDK generates with an
IsValid() boolmethod) are validated against the SDK's own check via reflection.Bad input does not abort the build. It re-prompts in place (non-numeric integer, malformed JSON, empty required field) using validators.
A depth/cycle guard degrades self-referential structures to a raw-JSON prompt instead of looping forever.
Design notes
SDK-agnostic: the package imports only
pkg/iostreamsandpkg/prompt, no SDK or command packages, so any command can reuse it.Input is gathered through a small
Prompterinterface (Input/Confirm/Select/MultiSelect). Production usesSurveyPrompter; tests useScriptedPrompter, a label-keyed fake whose answers match by field label rather than call order, so tests do not break when SDK changes.Changes
pkg/interactive/prompter.go:Prompterinterface andSurveyPrompter(survey/v2 viapkg/prompt).pkg/interactive/scripted_prompter.go:ScriptedPrompter, the reusable label-keyed test fake.pkg/interactive/builder.go: the reflection walk (scalars, optional pointers, unions, parameter bags, slices, maps, enum and input validation).pkg/interactive/classify.go: reflection helpers (union, parameter-bag, required-field detection).pkg/interactive/validators.go: input validator builders (required string, integer, number, boolean, JSON).Tests
Unit tests under
pkg/interactivecovering:map[string]string