// Command streamdeck-ctl inspects and edits the streamdeck-go configuration // from outside the daemon. // // It exists so that front-ends — principally the Omarchy shell plugin — can read // a machine-readable snapshot of the deck and rewrite keys without reimplementing // YAML handling or device probing. Every mutating command writes config.yaml // atomically; the daemon's fsnotify watcher picks the change up and hot-reloads, // so edits take effect without restarting anything. // // All output is JSON on stdout. Errors go to stderr and set a non-zero exit code. package main import ( "encoding/json" "flag" "fmt" "os" "os/exec" "runtime" "strconv" "strings" "git.i0t.app/lwoodard/streamdeck-go/internal/config" "git.i0t.app/lwoodard/streamdeck-go/internal/modules" ) func main() { cfgPath := flag.String("config", config.DefaultConfigPath(), "path to config file") flag.Usage = usage flag.Parse() args := flag.Args() if len(args) == 0 { usage() os.Exit(2) } var err error switch args[0] { case "status": err = cmdStatus(*cfgPath) case "brightness": err = cmdBrightness(*cfgPath, args[1:]) case "key": err = cmdKey(*cfgPath, args[1:]) case "daemon": err = cmdDaemon(args[1:]) case "help", "-h", "--help": usage() return default: err = fmt.Errorf("unknown command %q", args[0]) } if err != nil { fmt.Fprintf(os.Stderr, "streamdeck-ctl: %v\n", err) // Emit a JSON error too, so a front-end parsing stdout gets something // structured rather than an empty buffer. emit(map[string]any{"ok": false, "error": err.Error()}) os.Exit(1) } } func usage() { fmt.Fprint(os.Stderr, `streamdeck-ctl — inspect and edit the streamdeck-go config usage: streamdeck-ctl [-config PATH] [args] commands: status print a JSON snapshot: device, daemon, keys, icons, modules brightness <0-100> set deck brightness (hot-reloaded by the daemon) key set [flags] set or merge fields on a key key clear remove a key entirely key press run the key's command now daemon key set flags (only the flags you pass are written): -icon NAME -icon-true NAME -icon-false NAME -text STR -text-color STR -text-command STR -refresh DUR -command STR -module NAME -function NAME -folder NAME (pressing the key opens that folder) -param K=V (repeatable) -poll-command STR -poll-match STR -poll-interval DUR -unset FIELD (repeatable — removes a field) `) } func emit(v any) { enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") _ = enc.Encode(v) } // ── brightness ─────────────────────────────────────────────────────────────── func cmdBrightness(cfgPath string, args []string) error { if len(args) != 1 { return fmt.Errorf("brightness: expected a value 0-100") } value, err := strconv.Atoi(args[0]) if err != nil { return fmt.Errorf("brightness: %q is not a number", args[0]) } if value < 0 || value > 100 { return fmt.Errorf("brightness: %d is out of range (0-100)", value) } if err := config.SetBrightness(cfgPath, value); err != nil { return err } emit(map[string]any{"ok": true, "brightness": value}) return nil } // ── key ────────────────────────────────────────────────────────────────────── func cmdKey(cfgPath string, args []string) error { if len(args) < 2 { return fmt.Errorf("key: expected ") } action := args[0] index, err := strconv.Atoi(args[1]) if err != nil { return fmt.Errorf("key: %q is not a key index", args[1]) } if index < 0 { return fmt.Errorf("key: index %d is negative", index) } switch action { case "set": return keySet(cfgPath, index, args[2:]) case "clear": if err := config.ClearKey(cfgPath, index); err != nil { return err } emit(map[string]any{"ok": true, "index": index, "cleared": true}) return nil case "press": return keyPress(cfgPath, index) default: return fmt.Errorf("key: unknown action %q", action) } } // stringList collects a repeatable string flag. type stringList []string func (s *stringList) String() string { return strings.Join(*s, ",") } func (s *stringList) Set(v string) error { *s = append(*s, v); return nil } func keySet(cfgPath string, index int, args []string) error { fs := flag.NewFlagSet("key set", flag.ContinueOnError) // Flag name → YAML field name. Only flags actually passed get written, so a // front-end can PATCH a single field without clobbering the rest of the key. scalars := map[string]*string{ "icon": fs.String("icon", "", "icon filename inside icons_dir"), "icon-true": fs.String("icon-true", "", "icon shown when poll matches"), "icon-false": fs.String("icon-false", "", "icon shown when poll does not match"), "text": fs.String("text", "", "text overlay (\\n for line breaks)"), "text-color": fs.String("text-color", "", "white|black|red|blue|#RRGGBB"), "text-command": fs.String("text-command", "", "command whose stdout becomes the overlay text"), "refresh": fs.String("refresh", "", "how often to re-run text-command, e.g. 1s"), "command": fs.String("command", "", "shell command to run on press"), "folder": fs.String("folder", "", "folder (from the folders: block) the key opens"), "module": fs.String("module", "", "module name from modules.yaml"), "function": fs.String("function", "", "function name within the module"), "poll-command": fs.String("poll-command", "", "command used to poll toggle state"), "poll-match": fs.String("poll-match", "", "substring in poll output meaning 'on'"), "poll-interval": fs.String("poll-interval", "", "poll interval, e.g. 2s"), } yamlField := map[string]string{ "icon": "icon", "icon-true": "icon_true", "icon-false": "icon_false", "text": "text", "text-color": "text_color", "text-command": "text_command", "refresh": "refresh", "command": "command", "module": "module", "function": "function", "folder": "folder", } var params stringList var unset stringList fs.Var(¶ms, "param", "module parameter as KEY=VALUE (repeatable)") fs.Var(&unset, "unset", "remove a field (repeatable)") if err := fs.Parse(args); err != nil { return err } edit := config.KeyEdit{Set: map[string]any{}} poll := map[string]string{} pollTouched := false // flag.Visit only reports flags the caller actually set — that distinction is // what makes this a merge rather than a full overwrite. fs.Visit(func(f *flag.Flag) { ptr, ok := scalars[f.Name] if !ok { return } if field, isTop := yamlField[f.Name]; isTop { edit.Set[field] = *ptr return } switch f.Name { case "poll-command": poll["command"] = *ptr pollTouched = true case "poll-match": poll["match"] = *ptr pollTouched = true case "poll-interval": poll["interval"] = *ptr pollTouched = true } }) if len(params) > 0 { parsed := map[string]string{} for _, kv := range params { name, value, found := strings.Cut(kv, "=") if !found || name == "" { return fmt.Errorf("key set: -param %q is not KEY=VALUE", kv) } parsed[name] = value } edit.Set["params"] = parsed } if pollTouched { // The poll block is written whole: it is small, and merging into a nested // mapping would need the existing values read back anyway. existing := existingPoll(cfgPath, index) for name, value := range poll { existing[name] = value } for name, value := range existing { if value == "" { delete(existing, name) } } if len(existing) == 0 { edit.Unset = append(edit.Unset, "poll") } else { edit.Set["poll"] = existing } } edit.Unset = append(edit.Unset, normalizeUnset(unset)...) if len(edit.Set) == 0 && len(edit.Unset) == 0 { return fmt.Errorf("key set: nothing to change — pass at least one field flag") } if err := config.ApplyKeyEdit(cfgPath, index, edit); err != nil { return err } emit(map[string]any{"ok": true, "index": index, "set": edit.Set, "unset": edit.Unset}) return nil } // normalizeUnset accepts either flag-style or YAML-style field names, so callers // can pass -unset icon-true or -unset icon_true. func normalizeUnset(names []string) []string { out := make([]string, 0, len(names)) for _, name := range names { out = append(out, strings.ReplaceAll(strings.TrimSpace(name), "-", "_")) } return out } // existingPoll reads the current poll block for a key so partial poll edits merge // instead of dropping the fields the caller didn't mention. func existingPoll(cfgPath string, index int) map[string]string { out := map[string]string{} cfg, err := config.Load(cfgPath) if err != nil { return out } key, ok := cfg.Keys[index] if !ok || key.Poll == nil { return out } if key.Poll.Command != "" { out["command"] = key.Poll.Command } if key.Poll.Match != "" { out["match"] = key.Poll.Match } if key.Poll.Interval != "" { out["interval"] = key.Poll.Interval } return out } // keyPress runs the key's command exactly as the daemon would, including module // resolution, so a front-end can fire a key without touching the hardware. func keyPress(cfgPath string, index int) error { cfg, err := config.Load(cfgPath) if err != nil { return err } key, ok := cfg.Keys[index] if !ok { return fmt.Errorf("key %d is not configured", index) } if key.Folder != "" && key.Command == "" && key.Module == "" { return fmt.Errorf("key %d opens folder %q — navigation only happens on the deck", index, key.Folder) } command := key.Command if key.Module != "" && key.Function != "" { reg, err := modules.LoadRegistry(config.ModulesPath(cfgPath)) if err != nil { return err } command, err = reg.Resolve(key.Module, key.Function, key.Params) if err != nil { return err } } if strings.TrimSpace(command) == "" { return fmt.Errorf("key %d has no command to run", index) } if after, found := strings.CutPrefix(command, "priv:"); found { return fmt.Errorf("key %d runs privileged command %q — press it on the deck instead", index, after) } // Mirror the daemon's press semantics: fire and forget. Waiting here would // wedge the caller (and the panel's serialized action pipeline) on any key // that launches a long-lived app. The exit code covers "could not start"; // the command's own outcome is its business, as on the hardware. cmd := exec.Command("sh", "-c", command) cmd.Env = pressEnvironment() if err := cmd.Start(); err != nil { return fmt.Errorf("key %d: start command: %w", index, err) } go func() { _ = cmd.Wait() }() // reap if it finishes before we exit emit(map[string]any{"ok": true, "index": index, "started": true}) return nil } // pressEnvironment builds the env a pressed key runs under. The daemon runs a // key with the systemd user manager's environment, but this CLI is usually // invoked from the shell (a different, often smaller env) — so a module key // whose token was imported only into the user manager would work on hardware // and silently no-op from the panel. Merging the manager's variables over the // caller's closes that gap on Linux; macOS keeps the caller's env (launchd has // no equivalent bulk query). func pressEnvironment() []string { env := os.Environ() if runtime.GOOS == "darwin" { return env } out, err := exec.Command("systemctl", "--user", "show-environment").Output() if err != nil { return env } // Overlay by name rather than appending: with duplicate keys in an env // array, which copy getenv returns is libc-dependent. merged := make(map[string]string, len(env)) order := make([]string, 0, len(env)) set := func(line string) { name, value, found := strings.Cut(line, "=") if !found || name == "" { return } if _, seen := merged[name]; !seen { order = append(order, name) } merged[name] = value } for _, line := range env { set(line) } for _, line := range strings.Split(string(out), "\n") { // systemd shell-quotes values that need it ($'...'); those few can't be // used verbatim, and dropping them beats injecting mangled quoting. if strings.Contains(line, "=$'") { continue } // Manager value wins over the caller's: that's what the hardware sees. set(line) } result := make([]string, 0, len(order)) for _, name := range order { result = append(result, name+"="+merged[name]) } return result } // ── daemon ─────────────────────────────────────────────────────────────────── func cmdDaemon(args []string) error { if len(args) != 1 { return fmt.Errorf("daemon: expected start|stop|restart|toggle|status") } action := args[0] if action == "toggle" { if daemonState().Active { action = "stop" } else { action = "start" } } switch action { case "status": emit(map[string]any{"ok": true, "daemon": daemonState()}) return nil case "start", "stop", "restart": if err := controlDaemon(action); err != nil { return err } emit(map[string]any{"ok": true, "action": action, "daemon": daemonState()}) return nil default: return fmt.Errorf("daemon: unknown action %q", action) } } func controlDaemon(action string) error { if runtime.GOOS == "darwin" { label := "com.woodarddigital.streamdeck-go" plist := os.Getenv("HOME") + "/Library/LaunchAgents/" + label + ".plist" var cmd *exec.Cmd switch action { case "start": cmd = exec.Command("launchctl", "load", "-w", plist) case "stop": cmd = exec.Command("launchctl", "unload", "-w", plist) case "restart": _ = exec.Command("launchctl", "unload", "-w", plist).Run() cmd = exec.Command("launchctl", "load", "-w", plist) } if out, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("launchctl %s: %v: %s", action, err, strings.TrimSpace(string(out))) } return nil } out, err := exec.Command("systemctl", "--user", action, daemonUnit).CombinedOutput() if err != nil { return fmt.Errorf("systemctl --user %s %s: %v: %s", action, daemonUnit, err, strings.TrimSpace(string(out))) } return nil }