Files
streamdeck-go/cmd/streamdeck-init/main.go
2026-04-18 11:55:18 -06:00

678 lines
19 KiB
Go

// 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, shell command, or toggle key (with poll)
// - 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. a "delete key" flow), add a new function
// following the pattern of configureModuleKey/configureCommandKey/
// configureToggleKey: 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
switch actionType {
case "module":
entry, err = configureModuleKey(keyIdx, reg)
case "toggle":
entry, err = configureToggleKey(keyIdx, reg)
default:
entry, err = configureCommandKey(keyIdx)
}
if err != nil {
return err
}
// Step 4: Pick icon(s).
if entry.IsToggle {
fmt.Println(lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")).Render(" Icon for ON state:"))
iconTrue, err := pickIcon(cfg.IconsDir)
if err != nil {
return err
}
entry.IconTrue = iconTrue
fmt.Println(lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")).Render(" Icon for OFF state:"))
iconFalse, err := pickIcon(cfg.IconsDir)
if err != nil {
return err
}
entry.IconFalse = iconFalse
} else {
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)
if entry.IsToggle {
fmt.Printf(" Icon ON: %s\n", entry.IconTrue)
fmt.Printf(" Icon OFF: %s\n", entry.IconFalse)
} else {
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)
}
if entry.IsToggle {
fmt.Println(" Poll:")
if entry.PollModule != "" {
fmt.Printf(" Module: %s\n", entry.PollModule)
fmt.Printf(" Function: %s\n", entry.PollFunction)
if len(entry.PollParams) > 0 {
fmt.Println(" Params:")
for k, v := range entry.PollParams {
fmt.Printf(" %s: %s\n", k, v)
}
}
} else {
fmt.Printf(" Command: %s\n", entry.PollCommand)
}
if entry.PollMatch != "" {
fmt.Printf(" Match: %s\n", entry.PollMatch)
}
if entry.PollInterval != "" {
fmt.Printf(" Interval: %s\n", entry.PollInterval)
}
}
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, a raw shell command,
// or a toggle key with poll-based state checking.
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, OBS, etc.)", "module"),
huh.NewOption("Shell command", "command"),
huh.NewOption("Toggle key (with poll)", "toggle"),
).
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 &paramPtrs[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(&paramPtrs[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
}
// configureToggleKey walks the user through setting up a toggle key:
// press action (module or command) + poll configuration (module or command,
// match string, interval). Icons are handled separately in runOnce().
func configureToggleKey(keyIdx int, reg *modules.Registry) (keyEntry, error) {
entry := keyEntry{Index: keyIdx, IsToggle: true}
// Ask what happens when the key is pressed.
var pressType string
form := huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("What should pressing this key do?").
Options(
huh.NewOption("Module function", "module"),
huh.NewOption("Shell command", "command"),
).
Value(&pressType),
),
)
if err := form.Run(); err != nil {
return entry, err
}
if pressType == "module" {
modEntry, err := configureModuleKey(keyIdx, reg)
if err != nil {
return entry, err
}
entry.Module = modEntry.Module
entry.Function = modEntry.Function
entry.Params = modEntry.Params
} else {
cmdEntry, err := configureCommandKey(keyIdx)
if err != nil {
return entry, err
}
entry.Command = cmdEntry.Command
}
// Configure the poll block.
if err := configurePoll(&entry, reg); err != nil {
return entry, err
}
return entry, nil
}
// configurePoll walks through poll configuration for a toggle key: how to check
// state (module function or shell command), optional match string, and interval.
func configurePoll(entry *keyEntry, reg *modules.Registry) error {
var pollType string
form := huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("How should the key check its state?").
Options(
huh.NewOption("Module function", "module"),
huh.NewOption("Shell command", "command"),
).
Value(&pollType),
),
)
if err := form.Run(); err != nil {
return err
}
if pollType == "module" {
modEntry, err := configureModuleKey(entry.Index, reg)
if err != nil {
return err
}
entry.PollModule = modEntry.Module
entry.PollFunction = modEntry.Function
entry.PollParams = modEntry.Params
} else {
var cmd string
form := huh.NewForm(
huh.NewGroup(
huh.NewInput().
Title("Poll command").
Description("Shell command to check key state").
Value(&cmd).
Placeholder("e.g. pgrep -x myapp"),
),
)
if err := form.Run(); err != nil {
return err
}
entry.PollCommand = cmd
}
// Match string and interval.
var match, interval string
interval = "2s"
form = huh.NewForm(
huh.NewGroup(
huh.NewInput().
Title("Match string").
Description("Substring in output that means ON (leave empty to use exit code)").
Value(&match).
Placeholder("optional"),
huh.NewInput().
Title("Poll interval").
Description("How often to check state").
Value(&interval).
Placeholder("2s"),
),
)
if err := form.Run(); err != nil {
return err
}
entry.PollMatch = match
entry.PollInterval = interval
return 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
}