diff --git a/Makefile b/Makefile index d6c9dee..86a5063 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,6 @@ BINARY := streamdeck-go HELPER := streamdeck-helper +INIT := streamdeck-init PREFIX ?= $(HOME)/.local CONFIG_DIR := $(HOME)/.config/streamdeck-go GROUP := streamdeck @@ -23,7 +24,7 @@ else UDEV_RULE := /etc/udev/rules.d/99-streamdeck.rules endif -.PHONY: build build-helper install install-helper uninstall uninstall-helper udev +.PHONY: build build-helper build-init install install-helper uninstall uninstall-helper udev # ── Build ───────────────────────────────────────────────────────────────────── @@ -33,6 +34,9 @@ build: build-helper: go build -o $(HELPER) ./cmd/streamdeck-helper/ +build-init: + go build -o $(INIT) ./cmd/streamdeck-init/ + # ── Install ─────────────────────────────────────────────────────────────────── # Interactive install — prompts for dotfiles directory, installs binary + service. diff --git a/README.md b/README.md index 80aca0f..8ffec84 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ No Elgato software required — communicates directly with the device over USB H - No Stream Deck app, no Node.js, no Electron - **Modules** — define reusable, parameterised commands in `modules.yaml` with Go templates; secrets stay in env vars, config stays in dotfiles. First built-in example: Slack (status, presence, snooze). See [Modules](#modules). +- **Interactive config builder** — TUI tool (`streamdeck-init`) that walks you through key setup: pick a slot, pick a module/function, customize params, choose an icon. No YAML editing required. See [Config builder](#config-builder). **Planned:** text/label overlays on keys, multi-page layouts, AUR package — see [Roadmap](#roadmap) @@ -797,6 +798,87 @@ Two helpers are available in `exec` templates: --- +### Config builder + +Instead of editing YAML by hand, use the interactive TUI to configure keys: + +```bash +make build-init +./streamdeck-init +``` + +Or with a custom config path: + +```bash +./streamdeck-init -config ~/dotfiles/.config/streamdeck-go/config.yaml +``` + +The tool shows your 8x4 key grid, walks you through each step, and appends the result to your config.yaml: + +``` + Stream Deck XL — 8×4 grid + + ╭─────────╮╭─────────╮╭─────────╮╭─────────╮╭─────────╮╭─────────╮╭─────────╮╭─────────╮ + │Rootshell││ lights ││set_stat ││ WHH ││Dumpster ││ [5] ││ [6] ││ [7] │ + ╰─────────╯╰─────────╯╰─────────╯╰─────────╯╰─────────╯╰─────────╯╰─────────╯╰─────────╯ + ... + + [n] = free dim = occupied + + ? Pick a key slot + > 5 (free) + 6 (free) + 7 (free) + ... + + ? What should this key do? + > Module function (Slack, etc.) + Shell command + + ? Pick a module + > slack + + ? Pick a function from slack + > set_status + clear_status + set_presence + go_offline + snooze + end_snooze + + ? emoji + > :coffee: + + ? text + > Coffee break + + ? expiry + > 30m + + ? Pick an icon from ~/.config/streamdeck-go/icons + > coffee.png + + Summary: + Key: 5 + Icon: coffee.png + Module: slack + Function: set_status + Params: + emoji: :coffee: + text: Coffee break + expiry: 30m + + ? Write this key to config? Yes + ✓ Key 5 added to config. + + ? Add another key? No + ✓ Done — the daemon will auto-reload your config. +``` + +On a fresh install with no config files, the tool creates `config.yaml` and `modules.yaml` automatically. + +--- + ### Supported icon formats | Format | Notes | diff --git a/cmd/streamdeck-init/grid.go b/cmd/streamdeck-init/grid.go new file mode 100644 index 0000000..da18f7f --- /dev/null +++ b/cmd/streamdeck-init/grid.go @@ -0,0 +1,126 @@ +// grid.go — Renders the 8x4 Stream Deck key grid in the terminal. +// +// Uses lipgloss for styling: free slots are green with [index], occupied slots +// are dimmed with a short label (function name, icon name, or command prefix). +// +// ## Layout +// +// The grid mirrors the physical Stream Deck XL layout: +// +// 0 1 2 3 4 5 6 7 +// 8 9 10 11 12 13 14 15 +// 16 17 18 19 20 21 22 23 +// 24 25 26 27 28 29 30 31 +// +// ## Expanding +// +// To support non-XL models (e.g. MK.2 with 15 keys in a 5x3 grid), make +// gridCols and gridRows parameters instead of constants, and derive them +// from the device config's product_id. The device model → key count mapping +// is in internal/device/streamdeck.go. +// +// To add richer labels (e.g. showing params or the icon filename alongside +// the function name), modify keyLabel(). Keep labels under 8 chars to fit +// the cell width. + +package main + +import ( + "fmt" + "strings" + + "github.com/WoodardDigital/streamdeck-go/internal/config" + "github.com/charmbracelet/lipgloss" +) + +// Grid dimensions — hardcoded for Stream Deck XL (8 columns × 4 rows = 32 keys). +const ( + gridCols = 8 + gridRows = 4 + gridKeys = gridCols * gridRows +) + +// Styles for the grid cells. Color numbers are ANSI 256-color palette indices: +// +// 10 = bright green (free slots — draws the eye) +// 8 = dark gray (occupied slots — visually recedes) +// 12 = bright blue (title) +var ( + freeStyle = lipgloss.NewStyle().Width(9).Height(2).Align(lipgloss.Center, lipgloss.Center).Bold(true).Foreground(lipgloss.Color("10")) + occupiedStyle = lipgloss.NewStyle().Width(9).Height(2).Align(lipgloss.Center, lipgloss.Center).Foreground(lipgloss.Color("8")) + borderStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("8")) + titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")) +) + +// renderGrid builds a string containing the full 8x4 key grid with a legend. +// Free slots show [index] in green; occupied slots show a short label in gray. +// The grid is printed to stdout before the key-picking form. +func renderGrid(keys map[int]config.KeyConfig) string { + var b strings.Builder + + b.WriteString(titleStyle.Render(" Stream Deck XL — 8×4 grid")) + b.WriteString("\n\n") + + for row := 0; row < gridRows; row++ { + cells := make([]string, gridCols) + for col := 0; col < gridCols; col++ { + idx := row*gridCols + col + keyCfg, occupied := keys[idx] + + if occupied { + label := keyLabel(keyCfg) + cells[col] = borderStyle.Render(occupiedStyle.Render(label)) + } else { + cells[col] = borderStyle.Render(freeStyle.Render(fmt.Sprintf("[%d]", idx))) + } + } + // JoinHorizontal places cells side by side, aligned at the top. + b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, cells...)) + b.WriteString("\n") + } + + // Legend explaining the color coding. + b.WriteString("\n") + b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Render("[n]")) + b.WriteString(" = free ") + b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render("dim")) + b.WriteString(" = occupied\n") + + return b.String() +} + +// keyLabel returns a short display label for an occupied key (max 8 chars). +// +// Priority order: +// 1. Module function name (if module-based key) +// 2. Icon filename without extension (if static key) +// 3. Command prefix (fallback) +// 4. "???" (shouldn't happen with valid config) +func keyLabel(k config.KeyConfig) string { + if k.Module != "" { + label := k.Function + if len(label) > 8 { + label = label[:8] + } + return label + } + if k.Icon != "" { + label := k.Icon + // Strip file extension for brevity (e.g. "firefox.png" → "firefox"). + if dot := strings.LastIndex(label, "."); dot > 0 { + label = label[:dot] + } + if len(label) > 8 { + label = label[:8] + } + return label + } + if k.Command != "" { + label := k.Command + if len(label) > 8 { + label = label[:8] + } + return label + } + return "???" +} diff --git a/cmd/streamdeck-init/main.go b/cmd/streamdeck-init/main.go new file mode 100644 index 0000000..a1adc26 --- /dev/null +++ b/cmd/streamdeck-init/main.go @@ -0,0 +1,518 @@ +// 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 +} diff --git a/cmd/streamdeck-init/yaml.go b/cmd/streamdeck-init/yaml.go new file mode 100644 index 0000000..7f756a4 --- /dev/null +++ b/cmd/streamdeck-init/yaml.go @@ -0,0 +1,148 @@ +// yaml.go — Appends key entries to config.yaml without destroying comments. +// +// ## Why raw text append instead of yaml.Marshal? +// +// yaml.v3's Marshal destroys comments, reorders map keys, and normalises +// formatting. Users keep the default config's comments (key layout diagram, +// examples, etc.), so we must preserve them. The approach: +// +// - Parse config.yaml with config.Load() for DATA (which keys exist, icons_dir, etc.) +// - Write new keys by appending raw YAML text to the end of the file +// +// This works because keys: is always the last top-level block in config.yaml, +// and YAML maps are unordered, so appending a new 2-space-indented key entry +// to the end of the file is valid. +// +// ## Three cases handled by appendKeyToConfig +// +// Case A: "keys: {}" — the empty map literal from the default config generator. +// +// Replace {} with a newline, then insert the key block. This is the most +// common case on a fresh install. +// +// Case B: "keys:" with existing children — the normal case after keys exist. +// +// Append the new key block to the end of the file. It lands inside the +// keys: block because it's indented with 2 spaces. +// +// Case C: no "keys:" block at all — a malformed or minimal config. +// +// Append "keys:\n" then the key block. +// +// ## Expanding +// +// To support toggle keys (icon_true/icon_false + poll block), add those fields +// to keyEntry and extend renderKeySnippet() to emit the extra YAML lines. +// +// To support deleting or editing existing keys, you'd need a different strategy +// (e.g. yaml.v3 node-level manipulation). That's not in scope for the current +// append-only tool. + +package main + +import ( + "fmt" + "os" + "regexp" + "sort" + "strings" +) + +// keyEntry holds the data collected from the TUI forms, ready to be rendered +// as a YAML snippet and appended to config.yaml. +// +// Two mutually exclusive modes: +// - Module key: Module + Function + Params are set, Command is empty +// - Command key: Command is set, Module/Function/Params are empty +type keyEntry struct { + Index int // key slot (0-31) + Icon string // icon filename relative to icons_dir + Module string // module name from modules.yaml (e.g. "slack") + Function string // function name within the module (e.g. "set_status") + Params map[string]string // param overrides (merged with module defaults at runtime) + Command string // raw shell command (non-module keys only) +} + +// appendKeyToConfig reads config.yaml, appends a key entry as raw YAML text, +// and writes the file back. Preserves all existing content including comments. +func appendKeyToConfig(path string, entry keyEntry) error { + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read config: %w", err) + } + + content := string(data) + snippet := renderKeySnippet(entry) + + // Case A: keys: {} — empty map literal from the default config generator. + // Replace the {} with a newline so the snippet becomes the first child. + emptyKeys := regexp.MustCompile(`(?m)^keys:\s*\{\}\s*$`) + if emptyKeys.MatchString(content) { + content = emptyKeys.ReplaceAllString(content, "keys:\n"+snippet) + return os.WriteFile(path, []byte(content), 0644) + } + + // Case B: keys: exists with children — append snippet to end of file. + // The 2-space indent on the snippet places it inside the keys: block. + hasKeys := regexp.MustCompile(`(?m)^keys:\s*$`) + if hasKeys.MatchString(content) { + if !strings.HasSuffix(content, "\n") { + content += "\n" + } + content += snippet + return os.WriteFile(path, []byte(content), 0644) + } + + // Case C: no keys: block at all — append both the block header and snippet. + if !strings.HasSuffix(content, "\n") { + content += "\n" + } + content += "\nkeys:\n" + snippet + return os.WriteFile(path, []byte(content), 0644) +} + +// renderKeySnippet formats a keyEntry as an indented YAML block (2-space indent +// for the key index, 4-space for fields, 6-space for params). +// +// Module key output: +// +// 3: +// icon: status.png +// module: slack +// function: set_status +// params: +// emoji: ":brb:" +// text: "BRB" +// +// Command key output: +// +// 3: +// icon: firefox.png +// command: "open -a Firefox" +func renderKeySnippet(e keyEntry) string { + var b strings.Builder + + fmt.Fprintf(&b, " %d:\n", e.Index) + fmt.Fprintf(&b, " icon: %s\n", e.Icon) + + if e.Module != "" { + fmt.Fprintf(&b, " module: %s\n", e.Module) + fmt.Fprintf(&b, " function: %s\n", e.Function) + if len(e.Params) > 0 { + b.WriteString(" params:\n") + // Sort param keys for deterministic, diffable output. + keys := make([]string, 0, len(e.Params)) + for k := range e.Params { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + fmt.Fprintf(&b, " %s: \"%s\"\n", k, e.Params[k]) + } + } + } else { + fmt.Fprintf(&b, " command: \"%s\"\n", e.Command) + } + + return b.String() +} diff --git a/cmd/streamdeck/main.go b/cmd/streamdeck/main.go index 68ad09e..8d62e21 100644 --- a/cmd/streamdeck/main.go +++ b/cmd/streamdeck/main.go @@ -595,12 +595,7 @@ func loadPrivilegedCommands(path string) (map[string]string, error) { } func defaultConfigPath() string { - base := os.Getenv("XDG_CONFIG_HOME") - if base == "" { - home, _ := os.UserHomeDir() - base = filepath.Join(home, ".config") - } - return filepath.Join(base, "streamdeck-go", "config.yaml") + return config.DefaultConfigPath() } func ensureConfigDir(cfgPath string) error { diff --git a/go.mod b/go.mod index f93c6f1..749fb65 100644 --- a/go.mod +++ b/go.mod @@ -10,9 +10,34 @@ require ( ) require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/catppuccin/go v0.3.0 // indirect + github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect + github.com/charmbracelet/bubbletea v1.3.6 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/huh v1.0.0 // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.9.3 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/net v0.0.0-20211118161319-6a13c67c3ce4 // indirect - golang.org/x/sys v0.13.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.33.0 // indirect golang.org/x/text v0.35.0 // indirect ) diff --git a/go.sum b/go.sum index 4c26afb..2cdac12 100644 --- a/go.sum +++ b/go.sum @@ -1,17 +1,72 @@ +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= +github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= +github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= +github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw= +github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= +github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE= github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q= github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ= github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE= github.com/sstallion/go-hid v0.15.0 h1:WERW/VW3Us6N73V2qa7HjdqWQvwHd0CoRDOP/N707/w= github.com/sstallion/go-hid v0.15.0/go.mod h1:fPKp4rqx0xuoTV94gwKojsPG++KNKhxuU88goGuGM7I= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= golang.org/x/image v0.37.0 h1:ZiRjArKI8GwxZOoEtUfhrBtaCN+4b/7709dlT6SSnQA= golang.org/x/image v0.37.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY= golang.org/x/net v0.0.0-20211118161319-6a13c67c3ce4 h1:DZshvxDdVoeKIbudAdFEKi+f70l51luSy/7b76ibTY0= golang.org/x/net v0.0.0-20211118161319-6a13c67c3ce4/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= diff --git a/internal/config/config.go b/internal/config/config.go index 113d127..91c7048 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -36,6 +36,16 @@ type KeyConfig struct { Params map[string]string `yaml:"params"` } +// DefaultConfigPath returns the default config file path, respecting XDG_CONFIG_HOME. +func DefaultConfigPath() string { + base := os.Getenv("XDG_CONFIG_HOME") + if base == "" { + home, _ := os.UserHomeDir() + base = filepath.Join(home, ".config") + } + return filepath.Join(base, "streamdeck-go", "config.yaml") +} + // ModulesPath returns the path to the modules.yaml file next to the given config file. func ModulesPath(cfgPath string) string { return filepath.Join(filepath.Dir(cfgPath), "modules.yaml") diff --git a/internal/defaults/defaults.go b/internal/defaults/defaults.go new file mode 100644 index 0000000..a343875 --- /dev/null +++ b/internal/defaults/defaults.go @@ -0,0 +1,6 @@ +package defaults + +import _ "embed" + +//go:embed modules.example.yaml +var ModulesExampleYAML []byte diff --git a/internal/defaults/modules.example.yaml b/internal/defaults/modules.example.yaml new file mode 100644 index 0000000..9d13b65 --- /dev/null +++ b/internal/defaults/modules.example.yaml @@ -0,0 +1,58 @@ +# Example modules.yaml — copy to ~/.config/streamdeck-go/modules.yaml +# +# Required Slack token scopes: users.profile:write, users:write, dnd:write +# Export your token: export SLACK_TOKEN="xoxp-..." + +modules: + slack: + set_status: + params: + emoji: ":speech_balloon:" + text: "In a meeting" + expiry: "1h" + exec: | + curl -s -X POST https://slack.com/api/users.profile.set \ + -H "Authorization: Bearer {{env "SLACK_TOKEN"}}" \ + -H "Content-Type: application/json" \ + -d '{"profile":{"status_emoji":"{{.emoji}}","status_text":"{{.text}}","status_expiration":{{expiry .expiry}}}}' + + clear_status: + exec: | + curl -s -X POST https://slack.com/api/users.profile.set \ + -H "Authorization: Bearer {{env "SLACK_TOKEN"}}" \ + -H "Content-Type: application/json" \ + -d '{"profile":{"status_emoji":"","status_text":"","status_expiration":0}}' + + set_presence: + params: + presence: "away" + exec: | + curl -s -X POST https://slack.com/api/users.setPresence \ + -H "Authorization: Bearer {{env "SLACK_TOKEN"}}" \ + -H "Content-Type: application/json" \ + -d '{"presence":"{{.presence}}"}' + + snooze: + params: + minutes: "60" + exec: | + curl -s -X POST https://slack.com/api/dnd.setSnooze \ + -H "Authorization: Bearer {{env "SLACK_TOKEN"}}" \ + -H "Content-Type: application/json" \ + -d '{"num_minutes":{{.minutes}}}' + + go_offline: + exec: | + curl -s -X POST https://slack.com/api/users.setPresence \ + -H "Authorization: Bearer {{env "SLACK_TOKEN"}}" \ + -H "Content-Type: application/json" \ + -d '{"presence":"away"}' && \ + curl -s -X POST https://slack.com/api/users.profile.set \ + -H "Authorization: Bearer {{env "SLACK_TOKEN"}}" \ + -H "Content-Type: application/json" \ + -d '{"profile":{"status_emoji":"","status_text":"","status_expiration":0}}' + + end_snooze: + exec: | + curl -s -X POST https://slack.com/api/dnd.endSnooze \ + -H "Authorization: Bearer {{env "SLACK_TOKEN"}}" diff --git a/streamdeck-init b/streamdeck-init new file mode 100755 index 0000000..5d2d09f Binary files /dev/null and b/streamdeck-init differ