Skip to content

feat(interactive): add reflection-driven interactive prompt engine#249

Open
ben-kalmus wants to merge 1 commit into
mainfrom
feat/interactive-package
Open

feat(interactive): add reflection-driven interactive prompt engine#249
ben-kalmus wants to merge 1 commit into
mainfrom
feat/interactive-package

Conversation

@ben-kalmus

@ben-kalmus ben-kalmus commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

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 --interactive modes across the CLI.

  • This PR introduces the engine only. No command is wired to it yet (see the follow-up PR adding --interactive to compositions upsert).

Demo

compositions-upsert

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() bool method) 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/iostreams and pkg/prompt, no SDK or command packages, so any command can reuse it.

  • Input is gathered through a small Prompter interface (Input/Confirm/Select/MultiSelect). Production uses SurveyPrompter; tests use ScriptedPrompter, 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: Prompter interface and SurveyPrompter (survey/v2 via pkg/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/interactive covering:

  • Scalars, optional pointers (set and skipped), and enums (valid and rejected)
  • oneOf unions, parameter bags, slices, and map[string]string
  • Pre-populated fields preserved, and the depth/cycle guards falling back to raw JSON
  • Validator behavior and both the scripted and survey prompters

@codacy-production

codacy-production Bot commented Jun 17, 2026

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 236 complexity · 7 duplication

Metric Results
Complexity 236
Duplication 7

View in Codacy

TIP This summary will be updated as you push new changes.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 Builder that traverses structs (including optional pointers, unions, slices, maps, and “parameter bag” structs) and assigns values from prompts.
  • Added a Prompter abstraction 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.

Comment on lines +84 to +88
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
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +29 to +33
case *survey.Select:
*(response.(*string)) = "picked"
case *survey.MultiSelect:
*(response.(*[]int)) = []int{1}
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +44 to +50
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)
}
Comment thread pkg/interactive/builder.go Outdated
Comment on lines +66 to +68
case reflect.String, reflect.Bool, reflect.Int32, reflect.Int64, reflect.Float32, reflect.Float64:
_, err := b.assignScalar(label, rv, false)
return err
Comment thread pkg/interactive/builder.go Outdated
Comment on lines +173 to +193
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:
Comment on lines +219 to +230
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
}
Comment thread pkg/interactive/builder.go Outdated
Comment on lines +206 to +210
f, perr := strconv.ParseFloat(s, 64)
if perr != nil {
return false, fmt.Errorf("invalid number for %s: %w", label, perr)
}
v.SetFloat(f)
Comment on lines +76 to +89
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.
@ben-kalmus ben-kalmus force-pushed the feat/interactive-package branch from c17e3a6 to 9a22ae5 Compare June 19, 2026 16:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants