// streamdeck-init — Interactive TUI for configuring Stream Deck keys. // // This is a config builder, not an installer. It reads and appends to // ~/.config/streamdeck-go/config.yaml (or a custom path via -config). // It does NOT touch services, binaries, udev rules, or dependencies. // // ## How it works // // 1. Bootstrap: ensures config.yaml and modules.yaml exist (creates from // defaults/embedded example if missing). // 2. Loads both files using the same internal/config and internal/modules // packages the daemon uses. // 3. Renders an 8x4 key grid showing which slots are free vs occupied. // 4. Walks the user through a series of huh forms: // - Pick a key slot (0-31) // - Choose action type: module function or raw shell command // - If module: pick module → function → customize params (with defaults) // - If command: type a shell command // - Pick an icon from icons_dir (or type a custom filename) // - Confirm and append to config.yaml // 5. Loops: asks "Add another key?" after each addition. // // ## Expanding this tool // // To add new TUI steps (e.g. toggle key setup with poll config, or a // "delete key" flow), add a new function following the pattern of // configureModuleKey/configureCommandKey: build huh forms, collect values // into a keyEntry, return it for the confirm/append step. // // To support new key types in the YAML output, update keyEntry and // renderKeySnippet() in yaml.go. // // The grid rendering is in grid.go — it's hardcoded to 8x4 (XL). To // support other models, make gridCols/gridRows dynamic based on the // device config's product_id. // // ## Dependencies // // - github.com/charmbracelet/huh — form/prompt library (select, input, confirm) // - github.com/charmbracelet/lipgloss — terminal styling (grid rendering, summary output) // - internal/config — shared config loader and DefaultConfigPath() // - internal/modules — shared module registry loader // - internal/defaults — embedded modules.example.yaml for bootstrapping package main import ( "flag" "fmt" "os" "path/filepath" "sort" "strings" "github.com/WoodardDigital/streamdeck-go/internal/config" "github.com/WoodardDigital/streamdeck-go/internal/defaults" "github.com/WoodardDigital/streamdeck-go/internal/modules" "github.com/charmbracelet/huh" "github.com/charmbracelet/lipgloss" ) func main() { cfgPath := flag.String("config", config.DefaultConfigPath(), "path to config file") flag.Parse() // Bootstrap: ensure config dir, config.yaml, and modules.yaml exist. // On a fresh install this creates everything from scratch so the user // doesn't need to manually create files before running the TUI. if err := bootstrap(*cfgPath); err != nil { fmt.Fprintf(os.Stderr, "setup error: %v\n", err) os.Exit(1) } // Main loop — each iteration configures one key, then asks to continue. for { if err := runOnce(*cfgPath); err != nil { fmt.Fprintf(os.Stderr, "error: %v\n", err) os.Exit(1) } var again bool form := huh.NewForm( huh.NewGroup( huh.NewConfirm(). Title("Add another key?"). Value(&again), ), ) if err := form.Run(); err != nil { break } if !again { break } } fmt.Println(lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Bold(true).Render("✓") + " Done — the daemon will auto-reload your config.") } // bootstrap ensures the config directory, config.yaml, and modules.yaml exist. // // Creates: // - ~/.config/streamdeck-go/ (config dir) // - ~/.config/streamdeck-go/icons/ (icon storage) // - ~/.config/streamdeck-go/config.yaml (from inline default if missing) // - ~/.config/streamdeck-go/modules.yaml (from embedded example if missing) // // The embedded modules.example.yaml comes from internal/defaults, which uses // //go:embed so the init binary works standalone without the repo present. func bootstrap(cfgPath string) error { dir := filepath.Dir(cfgPath) iconsDir := filepath.Join(dir, "icons") if err := os.MkdirAll(iconsDir, 0755); err != nil { return err } // Create a minimal config.yaml if one doesn't exist yet. // Uses "keys: {}" (empty map) which appendKeyToConfig handles specially — // it replaces the {} with actual key entries on first append. if _, err := os.Stat(cfgPath); os.IsNotExist(err) { defaultCfg := fmt.Sprintf(`# streamdeck-go configuration icons_dir: %s brightness: 70 device: vendor_id: 0x0fd9 product_id: 0x00ba keys: {} `, iconsDir) if err := os.WriteFile(cfgPath, []byte(defaultCfg), 0644); err != nil { return err } fmt.Printf("Created %s\n", cfgPath) } // Copy the embedded modules.example.yaml so module functions are available // immediately. Users can edit this file to add custom modules later. modPath := config.ModulesPath(cfgPath) if _, err := os.Stat(modPath); os.IsNotExist(err) { if err := os.WriteFile(modPath, defaults.ModulesExampleYAML, 0644); err != nil { return err } fmt.Printf("Created %s\n", modPath) } return nil } // runOnce performs one complete key configuration cycle: // // load config → show grid → pick slot → pick action → pick icon → confirm → write // // Config is re-loaded at the start of each cycle so that keys added in the // previous iteration show up as occupied in the grid. func runOnce(cfgPath string) error { // Re-read config each iteration to pick up keys added in previous cycles. cfg, err := config.Load(cfgPath) if err != nil { return fmt.Errorf("load config: %w", err) } // Load the module registry to enumerate available modules/functions. reg, err := modules.LoadRegistry(config.ModulesPath(cfgPath)) if err != nil { return fmt.Errorf("load modules: %w", err) } // Show the 8x4 key grid so the user can see which slots are taken. fmt.Print(renderGrid(cfg.Keys)) // Step 1: Pick a key slot (0-31). keyIdx, err := pickKeySlot(cfg.Keys) if err != nil { return err } // Step 2: Choose between module function or raw shell command. actionType, err := pickActionType() if err != nil { return err } // Step 3: Configure the key based on action type. var entry keyEntry entry.Index = keyIdx if actionType == "module" { // Module path: module → function → params (with defaults pre-filled). entry, err = configureModuleKey(keyIdx, reg) } else { // Command path: just a shell command string. entry, err = configureCommandKey(keyIdx) } if err != nil { return err } // Step 4: Pick an icon file. icon, err := pickIcon(cfg.IconsDir) if err != nil { return err } entry.Icon = icon // Step 5: Show summary and confirm before writing. fmt.Println() fmt.Println(lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")).Render(" Summary:")) fmt.Printf(" Key: %d\n", entry.Index) fmt.Printf(" Icon: %s\n", entry.Icon) if entry.Module != "" { fmt.Printf(" Module: %s\n", entry.Module) fmt.Printf(" Function: %s\n", entry.Function) if len(entry.Params) > 0 { fmt.Println(" Params:") for k, v := range entry.Params { fmt.Printf(" %s: %s\n", k, v) } } } else { fmt.Printf(" Command: %s\n", entry.Command) } fmt.Println() var confirm bool form := huh.NewForm( huh.NewGroup( huh.NewConfirm(). Title("Write this key to config?"). Value(&confirm), ), ) if err := form.Run(); err != nil { return err } if !confirm { fmt.Println(" Skipped.") return nil } // Step 6: Append the YAML snippet to config.yaml. // This uses raw text append (not marshal/unmarshal) to preserve comments. if err := appendKeyToConfig(cfgPath, entry); err != nil { return fmt.Errorf("write config: %w", err) } fmt.Println(lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Bold(true).Render(" ✓") + fmt.Sprintf(" Key %d added to config.", entry.Index)) return nil } // pickKeySlot presents a select list of all 32 key slots, showing which are // occupied and what they're mapped to. Returns the selected key index. // // FUTURE: filter to show only free keys, or add a "overwrite?" confirmation // when an occupied slot is picked. func pickKeySlot(keys map[int]config.KeyConfig) (int, error) { options := make([]huh.Option[int], gridKeys) for i := 0; i < gridKeys; i++ { label := fmt.Sprintf("%-3d", i) if k, ok := keys[i]; ok { label += fmt.Sprintf(" (occupied — %s)", keyLabel(k)) } else { label += " (free)" } options[i] = huh.NewOption(label, i) } var keyIdx int form := huh.NewForm( huh.NewGroup( huh.NewSelect[int](). Title("Pick a key slot"). Options(options...). Value(&keyIdx), ), ) if err := form.Run(); err != nil { return 0, err } return keyIdx, nil } // pickActionType asks whether to configure a module function or a raw shell command. // // FUTURE: add "Toggle key (with poll)" as a third option, which would walk // through icon_true/icon_false and poll config setup. func pickActionType() (string, error) { var actionType string form := huh.NewForm( huh.NewGroup( huh.NewSelect[string](). Title("What should this key do?"). Options( huh.NewOption("Module function (Slack, etc.)", "module"), huh.NewOption("Shell command", "command"), ). Value(&actionType), ), ) if err := form.Run(); err != nil { return "", err } return actionType, nil } // configureModuleKey walks the user through: module → function → params. // // Modules and functions are enumerated dynamically from the loaded registry, // so any module added to modules.yaml automatically appears in the TUI. // // For each param defined in the function's FunctionDef.Params, an input field // is shown pre-filled with the default value. The user can accept defaults or // override per-key. Functions with no params (e.g. clear_status) skip this step. // // FUTURE: show a description/help text for each function (would require adding // a "description" field to FunctionDef in internal/modules/modules.go). func configureModuleKey(keyIdx int, reg *modules.Registry) (keyEntry, error) { entry := keyEntry{Index: keyIdx} if reg == nil || len(reg.Modules) == 0 { return entry, fmt.Errorf("no modules defined in modules.yaml") } // Pick module — enumerate all top-level keys from modules.yaml. modNames := make([]string, 0, len(reg.Modules)) for name := range reg.Modules { modNames = append(modNames, name) } sort.Strings(modNames) var modName string modOpts := make([]huh.Option[string], len(modNames)) for i, name := range modNames { modOpts[i] = huh.NewOption(name, name) } form := huh.NewForm( huh.NewGroup( huh.NewSelect[string](). Title("Pick a module"). Options(modOpts...). Value(&modName), ), ) if err := form.Run(); err != nil { return entry, err } entry.Module = modName // Pick function — enumerate all functions within the chosen module. mod := reg.Modules[modName] fnNames := make([]string, 0, len(mod)) for name := range mod { fnNames = append(fnNames, name) } sort.Strings(fnNames) var fnName string fnOpts := make([]huh.Option[string], len(fnNames)) for i, name := range fnNames { fnOpts[i] = huh.NewOption(name, name) } form = huh.NewForm( huh.NewGroup( huh.NewSelect[string](). Title(fmt.Sprintf("Pick a function from %s", modName)). Options(fnOpts...). Value(&fnName), ), ) if err := form.Run(); err != nil { return entry, err } entry.Function = fnName // Customize params — one input per param, pre-filled with the module default. // paramPtrs is a []string (not []*string) so that ¶mPtrs[i] gives a stable // pointer to each slice element — huh.NewInput().Value() needs a *string. fn := mod[fnName] if len(fn.Params) > 0 { paramKeys := make([]string, 0, len(fn.Params)) for k := range fn.Params { paramKeys = append(paramKeys, k) } sort.Strings(paramKeys) paramPtrs := make([]string, len(paramKeys)) paramFields := make([]huh.Field, len(paramKeys)) for i, k := range paramKeys { paramPtrs[i] = fn.Params[k] // pre-fill with default paramFields[i] = huh.NewInput(). Title(k). Value(¶mPtrs[i]). Placeholder(fn.Params[k]) } form = huh.NewForm( huh.NewGroup(paramFields...), ) if err := form.Run(); err != nil { return entry, err } // Collect the (possibly edited) values back into the entry. entry.Params = make(map[string]string, len(paramKeys)) for i, k := range paramKeys { entry.Params[k] = paramPtrs[i] } } return entry, nil } // configureCommandKey asks for a raw shell command string. // This is the non-module path — the command is written directly to config.yaml. func configureCommandKey(keyIdx int) (keyEntry, error) { entry := keyEntry{Index: keyIdx} var cmd string form := huh.NewForm( huh.NewGroup( huh.NewInput(). Title("Shell command"). Value(&cmd). Placeholder("e.g. open -a Firefox"), ), ) if err := form.Run(); err != nil { return entry, err } entry.Command = cmd return entry, nil } // pickIcon lets the user select an icon file. // // If icons_dir has image files, shows a select list of them plus a "type custom" // option. If the directory is empty, falls back to a plain text input. // // Supported extensions: .png, .jpg, .jpeg, .gif, .svg // // FUTURE: show icon previews (would require a terminal image protocol like // sixel or kitty graphics). For now, filenames are enough. func pickIcon(iconsDir string) (string, error) { // Scan icons_dir for image files. entries, err := os.ReadDir(iconsDir) if err != nil && !os.IsNotExist(err) { return "", err } var iconFiles []string for _, e := range entries { if e.IsDir() { continue } ext := strings.ToLower(filepath.Ext(e.Name())) switch ext { case ".png", ".jpg", ".jpeg", ".gif", ".svg": iconFiles = append(iconFiles, e.Name()) } } // No icons in directory — just ask for a filename. if len(iconFiles) == 0 { var icon string form := huh.NewForm( huh.NewGroup( huh.NewInput(). Title("Icon filename"). Value(&icon). Placeholder("e.g. myicon.png"), ), ) if err := form.Run(); err != nil { return "", err } return icon, nil } // Show available icons as a select list with a "custom" escape hatch. sort.Strings(iconFiles) options := make([]huh.Option[string], 0, len(iconFiles)+1) options = append(options, huh.NewOption("[ Type a custom filename ]", "__custom__")) for _, f := range iconFiles { options = append(options, huh.NewOption(f, f)) } var icon string form := huh.NewForm( huh.NewGroup( huh.NewSelect[string](). Title(fmt.Sprintf("Pick an icon from %s", iconsDir)). Options(options...). Value(&icon), ), ) if err := form.Run(); err != nil { return "", err } if icon == "__custom__" { form = huh.NewForm( huh.NewGroup( huh.NewInput(). Title("Icon filename"). Value(&icon). Placeholder("e.g. myicon.png"), ), ) if err := form.Run(); err != nil { return "", err } } return icon, nil }