CLIs built on huh break in CI, scripts, and agent-driven workflows. huh has no way to skip the TUI and accept answers programmatically, so every team that hits this hand-rolls the same workaround: parallel flag handling, TTY detection, duplicated validation logic, drifting code paths.
huhx fixes this. Build your form once. It runs as a beautiful TUI on a terminal and accepts CLI flags, environment variables, or YAML/JSON answer files everywhere else — CI pipelines, shell scripts, automated tooling.
Driving CLIs from agents. When an AI agent or orchestrator invokes
your CLI as a subprocess, huhx accepts answers via --answer key=val
flags, env vars, or an answer file — no TTY required and no separate
code path to maintain. When the agent is itself a Go program embedding
your form in-process, the same form can be driven via
WithAnswers(map[string]any{...}). Both surfaces reuse every validator
and every option the form already enforces. There is no separate
"headless mode" — huhx is the same form.
The wrapper mirrors huh's full chainable API; existing huh code ports by changing import paths.
go get github.com/cabljac/huhxpackage main
import (
"fmt"
"os"
"charm.land/huh/v2"
"github.com/cabljac/huhx"
"github.com/spf13/cobra"
)
func main() {
var (
name string
environment string
allRegions bool
)
cmd := &cobra.Command{
Use: "deploy",
RunE: func(cmd *cobra.Command, args []string) error {
form := huhx.NewForm(
huhx.NewGroup(
huhx.NewInput().Key("name").Title("App name").Value(&name),
huhx.NewSelect[string]().Key("environment").Title("Environment").
Options(
huh.NewOption("staging", "staging"),
huh.NewOption("prod", "prod"),
).Value(&environment),
),
huhx.NewGroup(
huhx.NewConfirm().Key("all-regions").Title("Deploy to all regions?").Value(&allRegions),
).WithHideFunc(func() bool { return environment != "prod" }),
)
runner := huhx.New(form,
huhx.WithEnvPrefix("DEPLOY"),
huhx.WithCobraFlags(cmd),
)
return runner.Run()
},
}
flags := cmd.Flags()
flags.String("name", "", "")
flags.String("environment", "", "")
flags.Bool("all-regions", false, "")
flags.StringArray("answer", nil, "additional key=val answers")
flags.Bool("non-interactive", false, "force non-interactive mode")
if err := cmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}# interactive
go run ./examples/deploy
# non-interactive
CI=1 go run ./examples/deploy \
--answer name=myapp \
--answer environment=prod \
--answer all-regions=truehuhx.AutoDetect (default) picks non-interactive when any of:
NON_INTERACTIVE=1orCI=1is set.- stdin is not a TTY.
--non-interactiveflag is present on the wired cobra command.
Otherwise the runner delegates to huh.Form.Run().
Force the mode with huhx.WithNonInteractive(huhx.Always | huhx.Never).
When non-interactive, each field's answer is resolved in order:
WithAnswers(map[string]any{...})— programmatic injection.- Cobra named flag matching the field key (e.g.
--name). --answer key=valentries from aStringArrayflag namedanswer.- Answer file from
WithAnswerFile(path)(YAML or JSON). - Environment variable
<PREFIX>_<KEY>(withWithEnvPrefix). - Otherwise the field is reported as missing.
| huhx | wraps |
|---|---|
Input |
*huh.Input |
Text |
*huh.Text |
Confirm |
*huh.Confirm |
Select[T] |
*huh.Select[T] |
MultiSelect[T] |
*huh.MultiSelect[T] |
Each wrapper mirrors huh's chainable API. Validate(fn) stores the
validator on the wrapper so it runs against headless-injected values
without going through huh internals.
MultiSelect accepts comma-separated answers (a,b,c).
Confirm parses with strconv.ParseBool.
Select and MultiSelect support both:
Options(opts...)— static list captured at construction time.OptionsFunc(f, bindings)— dynamic provider re-evaluated lazily at injection time. Useful when the available choices depend on an earlier field's value (e.g. State depending on Country).
When using OptionsFunc, the dependent field must live in a later
group than its source field. The non-interactive runner walks groups
in order and writes each field's value before later groups resolve, so
closures capturing earlier-field pointers see the right values. This is
the same constraint huh's interactive bindings machinery already
enforces.
Calling Options(...) clears any prior OptionsFunc(...) and vice
versa — last setter wins.
Group.WithHide(bool) and Group.WithHideFunc(func() bool) skip the
group in non-interactive mode and hide it in interactive mode. Both
mirror huh's API exactly — WithHide takes a static bool, WithHideFunc
takes a predicate re-evaluated at run time.
missing required answers for:
--name (env: DEPLOY_NAME)
--environment (env: DEPLOY_ENVIRONMENT)
Migration is mostly mechanical — one decision per field.
// before
import "charm.land/huh/v2"
form := huh.NewForm(
huh.NewGroup(
huh.NewInput().Title("Name").Value(&name),
),
)
if err := form.Run(); err != nil { ... }// after
import (
"charm.land/huh/v2"
"github.com/cabljac/huhx"
)
form := huhx.NewForm(
huhx.NewGroup(
huhx.NewInput().Key("name").Title("Name").Value(&name),
),
)
runner := huhx.New(form,
huhx.WithEnvPrefix("MYAPP"),
huhx.WithCobraFlags(cmd), // if cobra is wired
)
if err := runner.Run(); err != nil { ... }Keep huh.NewOption, huh.Option[T], huh.Accessor[T], theme types,
etc. — huhx reuses huh's types unchanged.
Every field that should be drivable non-interactively needs a .Key(k).
The key becomes the CLI flag name (--my-key), the environment variable
suffix (PREFIX_MY_KEY), and the answer file key. Pick keys that read
well as flags — lowercase, hyphen-separated.
huhx.NewInput().Key("repo-name").Title("Repository name").Value(&repoName)
// non-interactive: --answer repo-name=... MYAPP_REPO_NAME=...Fields without .Key() still work in interactive mode — huhx forwards
them to huh as normal. Non-interactive behavior:
- Required keyless field → runner errors with
required field M in group N has no Key() set; call .Key("...") on it to enable non-interactive mode(1-based group + field index). Run the binary once in non-interactive mode, see which field needs a key, add it, repeat. - Optional keyless field (
.Optional()) → silently skipped in non-interactive mode.
That's the whole migration loop. The rest is search-and-replace.
Register matching flags on your cobra command so huhx can read named flag values:
cmd.Flags().String("repo-name", "", "")
cmd.Flags().StringArray("answer", nil, "additional answers in key=val form")
cmd.Flags().String("answer-file", "", "path to YAML/JSON answer file")
cmd.Flags().Bool("non-interactive", false, "force non-interactive mode")Then pass huhx.WithCobraFlags(cmd) to the runner.