Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changesets/1791409317-72b900b2.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
id: 1791409317-72b900b2
features:
- core
targets:
- cli
type: chore
bump: patch
description: replace the huh form library with an in-repo forms package
author: ThomasRooney
date: "2026-10-07"
3 changes: 3 additions & 0 deletions templates/perms.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ var fileMode = map[string]fs.FileMode{
"templates/cli/auxiliary/internal/flagutil/flags.go.stmpl": 0o644,
"templates/cli/auxiliary/internal/flagutil/metadata.go.stmpl": 0o644,
"templates/cli/auxiliary/internal/flagutil/preset.go.stmpl": 0o644,
"templates/cli/auxiliary/internal/forms": 0o20000000755,
"templates/cli/auxiliary/internal/forms/forms.go.stmpl": 0o644,
"templates/cli/auxiliary/internal/interactive": 0o20000000755,
"templates/cli/auxiliary/internal/interactive/interactive.go.stmpl": 0o644,
"templates/cli/auxiliary/internal/interactive/policy.go.stmpl": 0o644,
Expand Down Expand Up @@ -187,6 +189,7 @@ var fileMode = map[string]fs.FileMode{
"templates/cli/tests/primary/exitcodes_test.go.stmpl": 0o644,
"templates/cli/tests/primary/flagutilmeta_test.go.stmpl": 0o644,
"templates/cli/tests/primary/flattening_test.go.stmpl": 0o644,
"templates/cli/tests/primary/forms_test.go.stmpl": 0o644,
"templates/cli/tests/primary/globals_additional_test.go.stmpl": 0o644,
"templates/cli/tests/primary/headers_additional_test.go.stmpl": 0o644,
"templates/cli/tests/primary/hooks_additional_test.go.stmpl": 0o644,
Expand Down
8 changes: 5 additions & 3 deletions templates/templates/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,9 @@ The generator follows a **generated commands + shared runtime** pattern:
│ keyring.go ─ OS keychain integration (go-keyring) │
│ interactive/ │
│ policy.go ─ Shared prompt/form/explorer policy │
│ interactive.go ─ Missing-flag prompting via huh forms │
│ interactive.go ─ Missing-flag prompting via forms │
│ forms/ │
│ forms.go ─ Input/select/confirm terminal forms │
│ explorer/ │
│ *.go ─ Interactive command explorer TUI │
│ testclient/ │
Expand Down Expand Up @@ -761,7 +763,7 @@ cli users create --no-interactive

- `interactive.Intercept` is installed after generated intents, both custom-command registration surfaces, Cobra's default help/completion commands, and declared command ordering. `usage.Intercept` is installed immediately afterward as the outer wrapper; `--usage`, `--schema`, and any other flag annotated as a documentation surface never open a prompt.
- `interactive.Resolve` is the single runtime gate for required-input prompts, `configure` / `auth login` forms, and direct explorer validation. `interactive.IsInteractive` remains a compatibility wrapper for hand-written commands. The compatibility-only bare-root auto-launch retains `shouldAutoExplore` so its process-entry behavior is unchanged. `interactiveByDefault` selects the generated default of `--interactive`; `--no-interactive` and active agent mode suppress prompts and forms.
- Required-input prompting needs both stdin and stdout to be TTYs unless a context-injected test prompter supplies terminal capability. Guided forms use the rich TUI on a terminal pair and huh's accessible line prompts off-TTY. Consequently, compatibility builds (`interactiveByDefault: true`) retain their existing off-TTY accessible forms, while non-interactive-by-default builds never read stdin for a form off-TTY unless `--interactive` is passed. `configure` and `auth login` resolve through `Decision.SetupFormMode`: run bare on a terminal pair they open the form regardless of `interactiveByDefault`, unless `--no-interactive`, `--interactive=false`, or agent mode is active. `configure --default-output-format` stores the default output format without a form; `--output-format` keeps controlling only the current command's rendering.
- Required-input prompting needs both stdin and stdout to be TTYs unless a context-injected test prompter supplies terminal capability. Guided forms use the rich TUI on a terminal pair and the accessible line prompts of `internal/forms` off-TTY. Consequently, compatibility builds (`interactiveByDefault: true`) retain their existing off-TTY accessible forms, while non-interactive-by-default builds never read stdin for a form off-TTY unless `--interactive` is passed. `configure` and `auth login` resolve through `Decision.SetupFormMode`: run bare on a terminal pair they open the form regardless of `interactiveByDefault`, unless `--no-interactive`, `--interactive=false`, or agent mode is active. `configure --default-output-format` stores the default output format without a form; `--output-format` keeps controlling only the current command's rendering.
- Direct `explore` is itself an explicit request, but it rejects `--no-interactive` / `--interactive=false` as `CLI_VALIDATION` (`validation_error`, exit 2) and requires both stdin and stdout to be TTYs before Bubble Tea is called. A missing terminal remains a runtime error (exit 1). Agent mode retains the existing behavior of showing root help instead.
- Interception is additive. When prompting is suppressed or no required promptable input is missing, the command keeps its existing behavior exactly: operation request builders report missing flags, bare required-input intents return typed usage help errors, and hand-written commands retain their own errors/help/exit codes. A `speakeasy:prompt-direct` field participates only after a missing required input has already triggered a form.
- Required flags are prompted when unresolved from **all** declared sources: an explicit/default-resolving flag, environment variable, config value, or changed whole-body flag for a body-derived field. Resolution checks are pure; environment and config values remain for the request builder to apply with its existing precedence. Piped stdin is deliberately not part of this check because a pipe makes the session non-interactive.
Expand Down Expand Up @@ -812,7 +814,7 @@ _ = interactive.Declare(cmd, interactive.CommandSpec{Args: []interactive.ArgSpec

Tests should use `interactive.WithPrompter(ctx, p)` (or the generated harness's `RunInteractive(args, p)`). The injected prompter substitutes only for the TTY check; it does not bypass a false `--interactive` value, `--no-interactive`, agent mode, usage, or documentation surfaces. The seam exposes stable field IDs (`arg:<name>`, `flag:<name>`), field order, applied values, and exported `PromptField.Direct` without driving Bubble Tea through a pipe.

**Startup cost**: the interactive packages (Bubble Tea, Lip Gloss, huh, bubbles) are the `charm.land/*/v2` modules. Unlike v1, they never query the terminal at package initialization, so every command starts without emitting background-color or cursor-position requests, including on PTYs that never answer. `tests/startup_tty_test.go` runs `version` on an unanswered pseudo-terminal and fails if a query or a stall reappears.
**Startup cost**: the interactive packages (Bubble Tea, Lip Gloss, bubbles) are the `charm.land/*/v2` modules, and `internal/forms` builds on them. Unlike v1, they never query the terminal at package initialization, so every command starts without emitting background-color or cursor-position requests, including on PTYs that never answer. `tests/startup_tty_test.go` runs `version` on an unanswered pseudo-terminal and fails if a query or a stall reappears.

**Generator config knobs**:

Expand Down
69 changes: 2 additions & 67 deletions templates/templates/cli/auth.go.stmpl
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,8 @@
{{- addImport "github.com/spf13/cobra" -}}
{{- addImport "internal/config" true -}}
{{- addImport "internal/flagutil" true -}}
{{- if templateHasSecuritySchemeGroups}}{{- addImport "internal/interactive" true -}}{{- end}}
{{- if templateHasSecuritySchemeGroups}}{{- addImport "internal/interactive" true -}}{{- addImport "internal/forms" true -}}{{- end}}
{{- addImport "fmt" -}}
{{- addImport "os" -}}
{{- addImport "golang.org/x/term" -}}
{{- addImport "charm.land/huh/v2" -}}
{{- addImport "charm.land/lipgloss/v2" -}}
package cli

{{genImports}}
Expand Down Expand Up @@ -84,7 +80,7 @@ This removes all credentials previously set via auth login or configure.`,
return nil
}

// runAuthLoginCmd executes the auth login command using huh forms.
// runAuthLoginCmd executes the auth login command using guided forms.
func runAuthLoginCmd(cmd *cobra.Command, args []string) error {
if dryRunLocalNoop(cmd, "auth login changes local credentials only (no API request); nothing was changed.") {
return nil
Expand Down Expand Up @@ -148,64 +144,3 @@ func runAuthLogoutCmd(cmd *cobra.Command, args []string) error {
fmt.Fprintf(out, "Configuration saved to %s\n", config.GetConfigPath())
return nil
}

// authFormTheme builds the form theme for auth login.
func authFormTheme() huh.Theme {
return huh.ThemeFunc(authFormStyles)
}

func authFormStyles(isDark bool) *huh.Styles {
t := *huh.ThemeBase(isDark)

accent := {{themeAccent}}
dimmed := {{themeDimmed}}
subtle := {{themeSubtle}}
errColor := {{themeError}}
greenColor := {{themeSuccess}}

t.Focused.Base = t.Focused.Base.
BorderLeft(true).
BorderStyle(lipgloss.ThickBorder()).
BorderForeground(accent).
PaddingLeft(1)
t.Focused.Title = t.Focused.Title.Foreground(accent).Bold(true)
t.Focused.Description = t.Focused.Description.Foreground(dimmed).Italic(true)
t.Focused.ErrorIndicator = t.Focused.ErrorIndicator.Foreground(errColor)
t.Focused.ErrorMessage = t.Focused.ErrorMessage.Foreground(errColor)
t.Focused.SelectSelector = t.Focused.SelectSelector.Foreground(accent).SetString("> ")
t.Focused.SelectedOption = t.Focused.SelectedOption.Foreground(accent).Bold(true)
t.Focused.SelectedPrefix = lipgloss.NewStyle().Foreground(greenColor).SetString("✓ ").Bold(true)
t.Focused.UnselectedPrefix = lipgloss.NewStyle().SetString(" ")
t.Focused.FocusedButton = t.Focused.FocusedButton.Background(accent).Foreground(lipgloss.Color("#FFFFFF"))
t.Focused.BlurredButton = t.Focused.BlurredButton.Background(subtle)
t.Focused.Next = t.Focused.FocusedButton

t.Focused.TextInput.Cursor = t.Focused.TextInput.Cursor.Foreground(accent)
t.Focused.TextInput.Placeholder = t.Focused.TextInput.Placeholder.Foreground(subtle).Italic(true)
t.Focused.TextInput.Prompt = t.Focused.TextInput.Prompt.Foreground(accent)

t.Blurred.Base = t.Blurred.Base.
BorderLeft(true).
BorderStyle(lipgloss.ThickBorder()).
BorderForeground(subtle).
PaddingLeft(1)
t.Blurred.Title = t.Blurred.Title.Foreground(dimmed)
t.Blurred.Description = t.Blurred.Description.Foreground(subtle).Italic(true)
t.Blurred.TextInput.Text = t.Blurred.TextInput.Text.Foreground(dimmed)
t.Blurred.TextInput.Placeholder = t.Blurred.TextInput.Placeholder.Foreground(subtle).Italic(true)
t.Blurred.SelectedOption = t.Blurred.SelectedOption.Foreground(dimmed)
t.Blurred.SelectSelector = t.Blurred.SelectSelector.Foreground(dimmed)
t.Blurred.SelectedPrefix = lipgloss.NewStyle().Foreground(dimmed).SetString("✓ ")
t.Blurred.UnselectedPrefix = lipgloss.NewStyle().SetString(" ")

return &t
}

// authFormWidth returns the terminal width for sizing huh forms.
func authFormWidth() int {
width, _, err := term.GetSize(int(os.Stdout.Fd()))
if err != nil || width <= 0 {
width = 80
}
return width
}
Loading
Loading