Add streamdeck-ctl JSON control CLI

streamdeck-ctl reports device/daemon/key status as JSON and edits the
config (brightness, key set/clear/press, daemon control) so front-ends
don't have to reimplement YAML handling or device probing.

- internal/config/edit.go: comment-preserving, node-level YAML edits with
  atomic writes; key indices written as !!int so config.Load accepts them.
- internal/device: add model names, Lookup() and Present() for probing
  without opening the HID handle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjuZBBfzXqZgxRhxzJvkkC
This commit is contained in:
Levi Woodard
2026-09-27 18:21:33 -06:00
parent cef6f72712
commit 8cff4f8418
6 changed files with 1399 additions and 3 deletions

443
cmd/streamdeck-ctl/main.go Normal file
View File

@@ -0,0 +1,443 @@
// 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] <command> [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 <index> [flags] set or merge fields on a key
key clear <index> remove a key entirely
key press <index> run the key's command now
daemon <start|stop|restart|toggle|status>
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
-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 <set|clear|press> <index>")
}
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"),
"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",
}
var params stringList
var unset stringList
fs.Var(&params, "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)
}
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
}