chore(cli): replace the huh form library with a built-in forms package

Merged
TH
9d ago
+2919-955

Why

Generated CLIs depended on charm.land/huh/v2 for their guided forms (configure, auth login, and the prompts for missing required inputs), yet used only a small part of it. Generated CLIs now carry a small forms package built around exactly those questions, which removes the dependency and its transitive modules.

What changed

  • New internal/forms package, generated when interactiveAuth or interactiveMode is enabled: forms.go (about 590 lines) shipped without tests. One Field type covers text input, secret input, multi-line JSON, single choice and yes/no. One Form model steps through pages of fields, built directly on bubbletea, bubbles (textinput, textarea) and lipgloss, using the CLI's theme colours.
  • API: forms.NewInput(&v).Title(…).Description(…).Placeholder(…).Password().Validate(fn), forms.NewText(&v), forms.NewSelect(&v, forms.NewOption(label, value)…), forms.NewConfirm(&b), forms.NewPage(title, fields…), forms.New(pages…).Accessible(b).IO(in, out).Run().
  • Keys:
    • enter and tab move forward. enter on the last field submits; tab on the last field does nothing.
    • shift+tab moves back, across pages too.
    • ctrl+c cancels with user aborted.
    • Selects use up/down, j/k, ctrl+p/ctrl+n, home/end (g/G) and ctrl+u/ctrl+d half pages. They wrap around and show a window of options that fits the terminal height. Long and multi-line labels wrap and stay aligned.
    • Confirms toggle with left/right/h/l, and y/n answers and moves on.
    • Text fields insert a newline with alt+enter or ctrl+j.
    • Pages taller than the terminal scroll to keep the focused field in view, with the page title kept alongside the first field. Selects size their option window to the space left after their heading. Narrow terminals (down to 8 columns) don't overflow. Dumb terminals always get line prompts.
    • Validation runs when moving forward and shows the error under the field.
  • Line-prompt (accessible) mode:
    • Fields are asked in order. A blank answer keeps the current value, non-blank answers are stored exactly as typed, and end of input stops prompting. Other read errors are returned from Run.
    • Prompts use the command's streams: configure and auth login write to stdout, and prompts for missing inputs write to stderr.
    • All answers go through one buffered reader, so piped input reaches every field.
    • Secrets are read without echo at a terminal, and from the next line otherwise.
  • Template call sites (auth.go, configure.go, interactive.go, includes/security.ts) use the new API. The per-file theme and width helpers are removed, as are the duplicated security-field emitters.
  • Dependencies: generated CLIs drop huh, catppuccin/go, hashstructure, go-humanize, x/exp/ordered and x/exp/strings. bubbletea and bubbles become direct dependencies whenever either interactive option is enabled.
  • Regenerated the review CLI (zSDKs/sdk-cli) and templates/perms.go, and added a patch changeset for cli.

Breaking for customised CLIs: custom code in the auth login #region custom-auth-* blocks that builds huh fields must move to the forms API above.

Testing

  • tests/primary/forms_test.go (33 tests) runs against the generated primary test CLI and isn't part of generated output. It mirrors the scenarios huh's own suite covers for these field types:

    • a multi-page walk-through with select validation
    • input typing, bound starting values, placeholders, password masking and paste
    • text newlines and paste
    • confirm toggles and y/Y/n/N
    • select cursor, wrap-around, home/end, half-page moves and multi-line labels
    • previous page, tab versus enter on the last field, and leading, middle and trailing empty pages
    • validation blocking forward moves only
    • titles and descriptions per field kind
    • rendering within the terminal width (including 8–20 columns), and reflow on resize
    • tall pages on short terminals in both directions, the page title with the first field, a field taller than the terminal, selects with headings on short terminals, and TERM=dumb
    • ctrl+c
    • line prompts for every field kind: defaults, invalid answers, surrounding whitespace, CRLF, piped multi-field input, end of input and read errors

    I checked the tests can fail by making deliberate behaviour-changing edits to forms.go: all 38 were caught.

  • Terminal matrix (out of tree). A configure-shaped form (8 credentials, global parameters, a select with a long label, a confirm) ran in a pseudo-terminal, with its output replayed into a terminal emulator of the same size. At every step the test checked that the user can see what they typed and that passwords stay masked, then checked every answer arrived:

    • sizes 8×4, 12×6, 16×5, 20×8, 30×10, 40×12, 60×15, 80×24, 100×30, 132×43, 200×50 and 300×80
    • shift+tab back up a page taller than the terminal
    • resizing mid-form to 40×12, 25×8, 200×60 and 80×24
    • TERM set to xterm-256color, xterm, vt100, screen and linux, plus NO_COLOR=1 and COLORTERM=truecolor
    • TERM=dumb
  • Out-of-tree pseudo-terminal smoke tests ran a built program through the interactive form (secret input, validation, the JSON newline, select, confirm, ctrl+c) and through line prompts at a terminal (secret not echoed).

  • TARGET=primary make test-cli: 1753 tests pass, including the forms suite, run with agent-detection environment variables unset.

  • TARGET=review make test-cli: 609 pass, 2 skipped.

  • Generated the review spec with each of the four interactiveAuth/interactiveMode combinations. Each builds and passes staticcheck, internal/forms contains only forms.go, and it and the charm modules appear only when an interactive option is enabled.

  • go generate ./..., prettier --check (3.0.3 from the lockfile) and make check-template-cli pass.

  • make lint steps 1–4 pass. The golangci-lint step didn't run locally because the installed binary was built with Go 1.25 and the repository targets Go 1.26.8.

Public-safety check

  • This change contains no credentials, customer documents, private repository URLs, private filesystem paths, or unredacted private logs.
  • Title, body, comments, and commit messages name no customers or customer-derived identifiers, private paths or trackers, or workflow provenance, and are understandable without private context (.claude/skills/public-repo-communication/SKILL.md).
  • Generated fixtures and review SDK changes are public-safe.
  • I reviewed git diff --check.