Install
$ agentstack add skill-alxxpersonal-forge-charm-huh ✓ scanned · ✓ verified, works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ✓ Network access No
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ● Environment & secrets Used
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
Verified badge
Passed review? Show it. Paste this badge into your README, it links to the public security report.
Reliability & compatibility
Declared compatibility
Compatibility is declared by the source manifest. End-to-end runtime verification is coming, see below.
We're building live execution health for every listing: tool-call success rate, median latency, uptime, and last-checked timestamps, measured, not self-reported. It isn't live yet, so we don't show numbers we can't stand behind.
How agent discovery & health will work →About
charmbracelet/huh
Interactive terminal forms and prompts for Go. Built on Bubble Tea.
Import: charm.land/huh/v2
Quick Start
package main
import (
"fmt"
"log"
"charm.land/huh/v2"
)
func main() {
var name string
var confirm bool
err := huh.NewForm(
huh.NewGroup(
huh.NewInput().
Title("What's your name?").
Value(&name).
Validate(huh.ValidateNotEmpty()),
huh.NewConfirm().
Title("Ready?").
Value(&confirm),
),
).Run()
if err != nil {
log.Fatal(err)
}
fmt.Printf("Hello, %s!\n", name)
}
Single field shorthand (no Form/Group wrapper needed):
var name string
huh.NewInput().Title("Name?").Value(&name).Run()
Core API
Architecture
Form > Group (pages) > Field (inputs)
Groups are displayed one at a time. The form advances to the next group when all fields in the current group pass validation.
Form
form := huh.NewForm(groups...*Group) *Form
Key methods:
| Method | Purpose | |--------|---------| | .Run() | Block and run the form | | .RunWithContext(ctx) | Run with context (supports cancellation) | | .WithTheme(theme) | Set theme | | .WithWidth(w) / .WithHeight(h) | Set dimensions | | .WithAccessible(bool) | Screen reader mode | | .WithShowHelp(bool) | Toggle help bar | | .WithShowErrors(bool) | Toggle error display | | .WithTimeout(duration) | Auto-cancel after duration | | .WithLayout(layout) | Set group layout | | .WithKeyMap(keymap) | Custom keybindings | | .WithOutput(w) / .WithInput(r) | Custom IO |
Retrieve values by key after completion:
form.GetString("key")
form.GetInt("key")
form.GetBool("key")
form.Get("key") // any
Form states: huh.StateNormal, huh.StateCompleted, huh.StateAborted
Errors: huh.ErrUserAborted (ctrl+c), huh.ErrTimeout
Group
group := huh.NewGroup(fields ...Field) *Group
| Method | Purpose | |--------|---------| | .Title(s) / .Description(s) | Group header | | .WithHide(bool) | Skip this group | | .WithHideFunc(func() bool) | Conditionally skip group | | .WithShowHelp(bool) | Toggle help for group |
Field Types
Every field supports: .Title(s), .Description(s), .Key(s), .Value(&v), .Validate(fn), .Run().
Dynamic variants exist for most properties: .TitleFunc(fn, binding), .DescriptionFunc(fn, binding), etc.
Input
Single line text. Type: string.
huh.NewInput().
Title("Email").
Placeholder("you@example.com").
Prompt("> ").
CharLimit(100).
Suggestions([]string{"gmail.com", "outlook.com"}).
EchoMode(huh.EchoModePassword). // or EchoModeNone
Inline(true). // title and input on same line
Validate(huh.ValidateNotEmpty()).
Value(&email)
Text
Multi-line textarea. Type: string.
huh.NewText().
Title("Description").
Lines(5).
CharLimit(500).
Placeholder("Enter details...").
ShowLineNumbers(true).
Editor("vim"). // external editor support (ctrl+e)
EditorExtension("md").
ExternalEditor(false). // disable external editor
Value(&description)
Select
Pick one from a list. Generic: Select[T comparable].
huh.NewSelect[string]().
Title("Country").
Options(
huh.NewOption("United States", "US"),
huh.NewOption("Canada", "CA"),
).
Height(8). // scrollable if options exceed height
Inline(true). // horizontal left/right navigation
Filtering(true). // start with filter active
Value(&country)
Shorthand for simple options:
Options(huh.NewOptions("Warrior", "Mage", "Rogue")...)
MultiSelect
Pick zero or more. Generic: MultiSelect[T comparable].
huh.NewMultiSelect[string]().
Title("Toppings").
Options(
huh.NewOption("Lettuce", "lettuce").Selected(true),
huh.NewOption("Tomato", "tomato"),
huh.NewOption("Cheese", "cheese"),
).
Limit(3).
Height(6).
Filterable(false). // disable "/" filter
Value(&toppings)
Space to toggle, a to select all (when no limit), / to filter.
Confirm
Yes/No. Type: bool.
huh.NewConfirm().
Title("Continue?").
Affirmative("Yes!").
Negative("No way").
Inline(true).
Value(&ok)
Keys: h/l or left/right to toggle, y to accept, n to reject.
Note
Display-only. Not interactive by default (auto-skipped).
huh.NewNote().
Title("Welcome").
Description("This form collects your _preferences_.").
Height(10).
Next(true). // show a "Next" button, makes it interactive
NextLabel("Continue")
Description supports basic markdown: _italic_, *bold*, ` code `.
Common Patterns
Multi-Step Forms
Groups act as pages. Users navigate forward/back between them.
huh.NewForm(
huh.NewGroup(/* step 1 fields */).Title("Step 1"),
huh.NewGroup(/* step 2 fields */).Title("Step 2"),
huh.NewGroup(/* step 3 fields */).Title("Step 3"),
).Run()
Conditional Groups
Hide groups based on previous answers:
var wantExtras bool
huh.NewForm(
huh.NewGroup(
huh.NewConfirm().Title("Want extras?").Value(&wantExtras),
),
huh.NewGroup(
huh.NewInput().Title("Extra details").Value(&details),
).WithHideFunc(func() bool { return !wantExtras }),
).Run()
Dynamic Fields
Use *Func variants to recompute properties when bindings change. Pass a pointer to the bound variable.
var country string
huh.NewSelect[string]().
Value(&state).
TitleFunc(func() string {
if country == "Canada" { return "Province" }
return "State"
}, &country).
OptionsFunc(func() []huh.Option[string] {
return huh.NewOptions(statesByCountry[country]...)
}, &country)
The binding (&country) tells huh when to recompute. Results are cached per binding hash.
Validation
Built-in validators:
huh.ValidateNotEmpty()
huh.ValidateMinLength(3)
huh.ValidateMaxLength(100)
huh.ValidateLength(3, 100) // min and max
huh.ValidateOneOf("a", "b", "c")
Custom validation:
.Validate(func(s string) error {
if !strings.Contains(s, "@") {
return fmt.Errorf("must be a valid email")
}
return nil
})
Validation runs on blur (when leaving a field). Forms block progression if any field in the current group has errors.
Theming
Built-in themes: ThemeCharm (default), ThemeDracula, ThemeCatppuccin, ThemeBase16, ThemeDefault.
form.WithTheme(huh.ThemeFunc(huh.ThemeDracula))
Custom theme - implement the Theme interface:
type Theme interface {
Theme(isDark bool) *Styles
}
Or use ThemeFunc:
form.WithTheme(huh.ThemeFunc(func(isDark bool) *huh.Styles {
s := huh.ThemeCharm(isDark) // start from a base
s.Focused.Title = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("212"))
return s
}))
Layouts
form.WithLayout(huh.LayoutDefault) // one group at a time (default)
form.WithLayout(huh.LayoutStack) // all groups stacked vertically
form.WithLayout(huh.LayoutColumns(2)) // groups in 2 columns
form.WithLayout(huh.LayoutGrid(2, 3)) // 2 rows, 3 columns
Accessibility
accessible := os.Getenv("ACCESSIBLE") != ""
form.WithAccessible(accessible)
When TERM=dumb, accessible mode activates automatically. Replaces TUI with plain text prompts. Timeout is not supported in accessible mode.
Spinner
Separate package for loading indicators after form submission:
import "charm.land/huh/v2/spinner"
err := spinner.New().
Title("Processing...").
Action(func() { /* do work */ }).
Run()
Or with context:
go doWork()
spinner.New().Title("Working...").Context(ctx).Run()
Integration: Standalone vs Bubble Tea
Standalone
Call .Run() on a form or individual field. Blocks until complete.
form.Run()
// or
huh.NewInput().Title("Name?").Value(&name).Run()
Embedded in Bubble Tea
*huh.Form implements tea.Model. Use it as a component in your Bubble Tea app.
type Model struct {
form *huh.Form
}
func NewModel() Model {
return Model{
form: huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Key("class").
Options(huh.NewOptions("Warrior", "Mage", "Rogue")...).
Title("Choose your class"),
),
),
}
}
func (m Model) Init() tea.Cmd {
return m.form.Init()
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
form, cmd := m.form.Update(msg)
if f, ok := form.(*huh.Form); ok {
m.form = f
}
if m.form.State == huh.StateCompleted {
return m, tea.Quit
}
return m, cmd
}
func (m Model) View() string {
if m.form.State == huh.StateCompleted {
return fmt.Sprintf("You picked: %s", m.form.GetString("class"))
}
return m.form.View()
}
Key differences when embedded:
- Do NOT call
.Run(), use Init/Update/View cycle instead - Set
SubmitCmdandCancelCmdif you want custom behavior on form completion - Use
.Key("name")on fields, retrieve withform.GetString("name") - Check
form.Stateto know when the form is done - Type assert the Update result:
form.(*huh.Form)
Navigation methods available for programmatic control:
form.NextGroup(),form.PrevGroup()form.NextField(),form.PrevField()form.GetFocusedField()
Common Mistakes
- Forgetting
.Value(&v)- Without it, answers go nowhere. The field uses an internalEmbeddedAccessorthat you cannot read after form completes unless you use.Key()+form.GetString().
- Using
.Run()inside Bubble Tea - Never call.Run()on an embedded form. Use the Init/Update/View pattern.
- Missing type parameter on Select/MultiSelect -
huh.NewSelect[string]()nothuh.NewSelect(). The generic parameter determines the option value type.
- Dynamic binding without pointer -
TitleFunc(fn, country)will not work. Must beTitleFunc(fn, &country)with a pointer so huh can detect changes.
- Timeout in accessible mode -
WithTimeout()returnsErrTimeoutUnsupportedin accessible mode. Guard it.
- Not handling ErrUserAborted -
form.Run()returnshuh.ErrUserAbortedwhen user presses ctrl+c. Always check the error.
- Form outputs to stderr - By default, the TUI renders to stderr (stdout stays clean for piping). Use
.WithOutput(os.Stdout)to change.
Checklist
- [ ] Import
charm.land/huh/v2 - [ ] Every field that stores data has
.Value(&var)or.Key("name") - [ ] Custom validators return
nilon success,erroron failure - [ ] Dynamic fields use
*Funcvariants with pointer bindings - [ ] Accessible mode handled via env var or config flag
- [ ]
ErrUserAbortedhandled after.Run() - [ ] Embedded forms use Init/Update/View, not
.Run() - [ ] Form state checked via
form.State == huh.StateCompleted
Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- Author: alxxpersonal
- Source: alxxpersonal/forge
- License: MIT
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet, be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.