From 86b87961e5a523cf6afe1bffac47c3205c89f5ec Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Fri, 3 Jul 2026 10:48:52 -0700 Subject: [PATCH] feat(cli): add config command to read and set preferences Add a headless 'dispatch config' command with list, get, set, and path subcommands so preferences can be read and changed without opening the TUI or hand-editing config.json. Set validates the value and persists through the existing config save path. Wired into handleArgs and the bash, zsh, and PowerShell completion scripts. Closes #212 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 14 ++ cmd/dispatch/cli.go | 29 ++- cmd/dispatch/config.go | 394 ++++++++++++++++++++++++++++++++++++ cmd/dispatch/config_test.go | 319 +++++++++++++++++++++++++++++ cmd/dispatch/main.go | 8 + 5 files changed, 760 insertions(+), 4 deletions(-) create mode 100644 cmd/dispatch/config.go create mode 100644 cmd/dispatch/config_test.go diff --git a/README.md b/README.md index 676d77f..3f92c50 100644 --- a/README.md +++ b/README.md @@ -340,6 +340,20 @@ Configuration is stored in the platform-specific config directory: - **macOS**: `~/Library/Application Support/dispatch/config.json` - **Windows**: `%APPDATA%\dispatch\config.json` +### From the command line + +Read and change settings without opening the TUI or editing JSON by hand: + +```bash +dispatch config list # print every setting and its value +dispatch config list --json # same, as a single JSON object +dispatch config get launch_mode # print one value +dispatch config set launch_mode window +dispatch config path # print the config file path +``` + +`set` validates the value and writes through the same save path the TUI uses, so migrations and checks still run. Unknown keys and invalid values exit non-zero with a clear message. The keys match the option names in the table below. Set `auto_refresh_seconds` to `default` to clear it back to unset. + ### Options | Key | Type | Default | Description | diff --git a/cmd/dispatch/cli.go b/cmd/dispatch/cli.go index 64e90d1..66aea5b 100644 --- a/cmd/dispatch/cli.go +++ b/cmd/dispatch/cli.go @@ -104,6 +104,13 @@ func handleArgs(args []string, origStderr io.Writer, updateCh <-chan *update.Upd } return true, cleanup, "", nil + case "config": + if cErr := runConfig(os.Stdout, args); cErr != nil { + fmt.Fprintf(os.Stderr, "config: %v\n", cErr) + return true, cleanup, "", cErr + } + return true, cleanup, "", nil + case "--demo": c, demoErr := setupDemo() if demoErr != nil { @@ -181,7 +188,7 @@ func runCompletion(w io.Writer, shell string) error { const bashCompletionScript = `# bash completion for dispatch _dispatch_completion() { local cur="${COMP_WORDS[COMP_CWORD]}" - local commands="help version open doctor update completion stats" + local commands="help version open doctor update completion stats config" local flags="-h --help -v --version --demo --clear-cache --reindex" if [[ "${COMP_CWORD}" -eq 1 ]]; then @@ -193,15 +200,21 @@ _dispatch_completion() { COMPREPLY=( $(compgen -W "bash zsh powershell" -- "${cur}") ) return 0 fi + + if [[ "${COMP_WORDS[1]}" == "config" ]]; then + COMPREPLY=( $(compgen -W "list get set path" -- "${cur}") ) + return 0 + fi } complete -F _dispatch_completion dispatch disp ` const zshCompletionScript = `#compdef dispatch disp _dispatch_completion() { - local -a commands shells flags - commands=(help version open doctor update completion stats) + local -a commands shells flags configsubs + commands=(help version open doctor update completion stats config) shells=(bash zsh powershell) + configsubs=(list get set path) flags=(-h --help -v --version --demo --clear-cache --reindex) if (( CURRENT == 2 )); then @@ -213,20 +226,28 @@ _dispatch_completion() { _describe -t shells 'shell' shells return fi + + if [[ ${words[2]} == config ]]; then + _describe -t configsubs 'config subcommand' configsubs + return + fi } _dispatch_completion "$@" ` const powershellCompletionScript = `# PowerShell completion for dispatch -$script:DispatchCommands = @('help', 'version', 'open', 'doctor', 'update', 'completion', 'stats') +$script:DispatchCommands = @('help', 'version', 'open', 'doctor', 'update', 'completion', 'stats', 'config') $script:DispatchFlags = @('-h', '--help', '-v', '--version', '--demo', '--clear-cache', '--reindex') $script:DispatchShells = @('bash', 'zsh', 'powershell') +$script:DispatchConfigSubcommands = @('list', 'get', 'set', 'path') Register-ArgumentCompleter -Native -CommandName dispatch, disp -ScriptBlock { param($wordToComplete, $commandAst, $cursorPosition) $tokens = @($commandAst.CommandElements | ForEach-Object { $_.ToString() }) $values = if ($tokens.Count -ge 2 -and $tokens[1] -eq 'completion') { $script:DispatchShells + } elseif ($tokens.Count -ge 2 -and $tokens[1] -eq 'config') { + $script:DispatchConfigSubcommands } else { $script:DispatchCommands + $script:DispatchFlags } diff --git a/cmd/dispatch/config.go b/cmd/dispatch/config.go new file mode 100644 index 0000000..e675552 --- /dev/null +++ b/cmd/dispatch/config.go @@ -0,0 +1,394 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "sort" + "strconv" + "strings" + "time" + + "github.com/jongio/dispatch/internal/config" +) + +// configLoadFn and configSaveFn are package variables so tests can substitute +// the config read/write path, matching the seam pattern used elsewhere in this +// package (see cli.go and stats.go). +var ( + configLoadFn = config.Load + configSaveFn = config.Save + configPathFn = config.ConfigPath +) + +// configFieldKind labels the value type of a settable preference so list and +// JSON output can render it correctly and set can parse the value. +type configFieldKind string + +const ( + configKindString configFieldKind = "string" + configKindBool configFieldKind = "bool" + configKindInt configFieldKind = "int" +) + +// configField describes one preference that the config command can read and +// write. get returns the current value as a string ("" for unset). set parses +// and validates a string value into the config, returning an error on bad +// input. +type configField struct { + name string + kind configFieldKind + get func(*config.Config) string + set func(*config.Config, string) error +} + +// configFields returns the settable preferences in a stable display order. +// Only scalar settings are exposed; list/map settings (views, hidden sessions, +// notes, color schemes) are edited in the TUI or by hand. +func configFields() []configField { + return []configField{ + strField("default_shell", func(c *config.Config) *string { return &c.DefaultShell }), + strField("default_terminal", func(c *config.Config) *string { return &c.DefaultTerminal }), + enumField("default_time_range", func(c *config.Config) *string { return &c.DefaultTimeRange }, + config.TimeRange1h, config.TimeRange1d, config.TimeRange7d, config.TimeRangeAll), + enumField("default_sort", func(c *config.Config) *string { return &c.DefaultSort }, + config.SortFieldUpdated, config.SortFieldCreated, config.SortFieldTurns, config.SortFieldName, config.SortFieldFolder), + enumField("default_sort_order", func(c *config.Config) *string { return &c.DefaultSortOrder }, + config.SortOrderAsc, config.SortOrderDesc), + enumField("default_pivot", func(c *config.Config) *string { return &c.DefaultPivot }, + config.PivotNone, config.PivotFolder, config.PivotRepo, config.PivotBranch, config.PivotDate, config.PivotHost), + boolField("show_preview", func(c *config.Config) *bool { return &c.ShowPreview }), + maxSessionsField(), + boolField("yoloMode", func(c *config.Config) *bool { return &c.YoloMode }), + strField("agent", func(c *config.Config) *string { return &c.Agent }), + strField("model", func(c *config.Config) *string { return &c.Model }), + enumField("launch_mode", func(c *config.Config) *string { return &c.LaunchMode }, + config.LaunchModeInPlace, config.LaunchModeTab, config.LaunchModeWindow, config.LaunchModePane), + enumField("pane_direction", func(c *config.Config) *string { return &c.PaneDirection }, + config.PaneDirectionAuto, config.PaneDirectionRight, config.PaneDirectionDown, config.PaneDirectionLeft, config.PaneDirectionUp), + strField("custom_command", func(c *config.Config) *string { return &c.CustomCommand }), + boolField("ai_search", func(c *config.Config) *bool { return &c.AISearch }), + durationField("attention_threshold", func(c *config.Config) *string { return &c.AttentionThreshold }), + strField("theme", func(c *config.Config) *string { return &c.Theme }), + enumField("preview_position", func(c *config.Config) *string { return &c.PreviewPosition }, + config.PreviewPositionRight, config.PreviewPositionBottom, config.PreviewPositionLeft, config.PreviewPositionTop), + boolField("default_collapsed", func(c *config.Config) *bool { return &c.DefaultCollapsed }), + boolField("conversation_newest_first", func(c *config.Config) *bool { return &c.ConversationNewestFirst }), + boolField("workspace_recovery", func(c *config.Config) *bool { return &c.WorkspaceRecovery }), + boolField("redact_preview_secrets", func(c *config.Config) *bool { return &c.RedactPreviewSecrets }), + autoRefreshField(), + } +} + +// strField builds a free-form string preference. +func strField(name string, ptr func(*config.Config) *string) configField { + return configField{ + name: name, + kind: configKindString, + get: func(c *config.Config) string { return *ptr(c) }, + set: func(c *config.Config, v string) error { + *ptr(c) = v + return nil + }, + } +} + +// enumField builds a string preference restricted to a fixed set of values. +// An empty value is always allowed and clears the setting back to its default. +func enumField(name string, ptr func(*config.Config) *string, allowed ...string) configField { + return configField{ + name: name, + kind: configKindString, + get: func(c *config.Config) string { return *ptr(c) }, + set: func(c *config.Config, v string) error { + if v != "" { + valid := false + for _, a := range allowed { + if v == a { + valid = true + break + } + } + if !valid { + return fmt.Errorf("invalid value %q for %s (want one of: %s)", v, name, strings.Join(allowed, ", ")) + } + } + *ptr(c) = v + return nil + }, + } +} + +// boolField builds a boolean preference. It accepts the usual truthy/falsy +// spellings understood by strconv.ParseBool. +func boolField(name string, ptr func(*config.Config) *bool) configField { + return configField{ + name: name, + kind: configKindBool, + get: func(c *config.Config) string { return strconv.FormatBool(*ptr(c)) }, + set: func(c *config.Config, v string) error { + b, err := strconv.ParseBool(strings.TrimSpace(v)) + if err != nil { + return fmt.Errorf("invalid value %q for %s (want true or false)", v, name) + } + *ptr(c) = b + return nil + }, + } +} + +// maxSessionsField builds the max_sessions preference with a non-negative +// integer constraint. +func maxSessionsField() configField { + return configField{ + name: "max_sessions", + kind: configKindInt, + get: func(c *config.Config) string { return strconv.Itoa(c.MaxSessions) }, + set: func(c *config.Config, v string) error { + n, err := strconv.Atoi(strings.TrimSpace(v)) + if err != nil { + return fmt.Errorf("invalid value %q for max_sessions (want a whole number)", v) + } + if n < 0 { + return fmt.Errorf("max_sessions must be zero or greater, got %d", n) + } + c.MaxSessions = n + return nil + }, + } +} + +// durationField builds a preference whose value must parse as a positive Go +// duration (e.g. "15m", "1h"). An empty value clears the setting. +func durationField(name string, ptr func(*config.Config) *string) configField { + return configField{ + name: name, + kind: configKindString, + get: func(c *config.Config) string { return *ptr(c) }, + set: func(c *config.Config, v string) error { + if v != "" { + d, err := time.ParseDuration(v) + if err != nil || d <= 0 { + return fmt.Errorf("invalid value %q for %s (want a positive duration like 15m or 1h)", v, name) + } + } + *ptr(c) = v + return nil + }, + } +} + +// autoRefreshField builds the auto_refresh_seconds preference. It is stored as +// a pointer so "unset" and "0" are distinct: set "default" clears it to unset, +// and any integer sets an explicit interval (0 disables polling). +func autoRefreshField() configField { + return configField{ + name: "auto_refresh_seconds", + kind: configKindInt, + get: func(c *config.Config) string { + if c.AutoRefreshSeconds == nil { + return "" + } + return strconv.Itoa(*c.AutoRefreshSeconds) + }, + set: func(c *config.Config, v string) error { + trimmed := strings.TrimSpace(v) + if trimmed == "" || trimmed == "default" { + c.AutoRefreshSeconds = nil + return nil + } + n, err := strconv.Atoi(trimmed) + if err != nil { + return fmt.Errorf("invalid value %q for auto_refresh_seconds (want a whole number or \"default\")", v) + } + c.AutoRefreshSeconds = &n + return nil + }, + } +} + +// findConfigField returns the field with the given name, or false if unknown. +func findConfigField(name string) (configField, bool) { + for _, f := range configFields() { + if f.name == name { + return f, true + } + } + return configField{}, false +} + +// runConfig reads or writes user preferences from the command line. args is the +// full argument slice with args[0] == "config". +func runConfig(w io.Writer, args []string) error { + if w == nil { + w = io.Discard + } + + rest := args + if len(rest) > 0 { + rest = rest[1:] // drop the "config" token + } + + // No subcommand behaves like "list". + sub := "list" + if len(rest) > 0 { + sub = rest[0] + rest = rest[1:] + } + + switch sub { + case "list": + return runConfigList(w, rest) + case "get": + return runConfigGet(w, rest) + case "set": + return runConfigSet(w, rest) + case "path": + return runConfigPath(w, rest) + default: + return fmt.Errorf("unknown config subcommand %q (want list, get, set, or path)", sub) + } +} + +// runConfigList prints every setting and its current value. With --json the +// output is a single JSON object keyed by setting name. +func runConfigList(w io.Writer, args []string) error { + jsonOut := false + for _, arg := range args { + switch arg { + case "--json": + jsonOut = true + default: + return fmt.Errorf("config list does not take arguments, got %q", arg) + } + } + + cfg, err := configLoadFn() + if err != nil { + return err + } + + fields := configFields() + if jsonOut { + obj := make(map[string]any, len(fields)) + for _, f := range fields { + obj[f.name] = fieldJSONValue(f, cfg) + } + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(obj) + } + + width := 0 + for _, f := range fields { + if len(f.name) > width { + width = len(f.name) + } + } + for _, f := range fields { + fmt.Fprintf(w, "%-*s = %s\n", width, f.name, f.get(cfg)) + } + return nil +} + +// runConfigGet prints the current value of a single setting. +func runConfigGet(w io.Writer, args []string) error { + if len(args) == 0 { + return fmt.Errorf("config get requires a key (see config list for keys)") + } + if len(args) > 1 { + return fmt.Errorf("config get takes a single key, got %d arguments", len(args)) + } + key := args[0] + + field, ok := findConfigField(key) + if !ok { + return unknownConfigKeyErr(key) + } + + cfg, err := configLoadFn() + if err != nil { + return err + } + fmt.Fprintln(w, field.get(cfg)) + return nil +} + +// runConfigSet validates and writes a single setting, persisting through the +// existing config save path so validation and migrations still run. +func runConfigSet(w io.Writer, args []string) error { + if len(args) < 2 { + return fmt.Errorf("config set requires a key and a value") + } + if len(args) > 2 { + return fmt.Errorf("config set takes a key and a single value, got %d arguments", len(args)) + } + key, value := args[0], args[1] + + field, ok := findConfigField(key) + if !ok { + return unknownConfigKeyErr(key) + } + + cfg, err := configLoadFn() + if err != nil { + return err + } + if err := field.set(cfg, value); err != nil { + return err + } + if err := configSaveFn(cfg); err != nil { + return err + } + fmt.Fprintf(w, "%s = %s\n", field.name, field.get(cfg)) + return nil +} + +// runConfigPath prints the resolved config file path. +func runConfigPath(w io.Writer, args []string) error { + if len(args) > 0 { + return fmt.Errorf("config path does not take arguments, got %q", args[0]) + } + path, err := configPathFn() + if err != nil { + return err + } + fmt.Fprintln(w, path) + return nil +} + +// fieldJSONValue returns the setting value typed for JSON output: bool as a +// JSON boolean, int as a number (or null when unset), and everything else as a +// string. +func fieldJSONValue(f configField, cfg *config.Config) any { + raw := f.get(cfg) + switch f.kind { + case configKindBool: + b, err := strconv.ParseBool(raw) + if err != nil { + return raw + } + return b + case configKindInt: + if raw == "" { + return nil + } + n, err := strconv.Atoi(raw) + if err != nil { + return raw + } + return n + default: + return raw + } +} + +// unknownConfigKeyErr builds a consistent error for an unrecognized key. +func unknownConfigKeyErr(key string) error { + names := make([]string, 0, len(configFields())) + for _, f := range configFields() { + names = append(names, f.name) + } + sort.Strings(names) + return fmt.Errorf("unknown config key %q (valid keys: %s)", key, strings.Join(names, ", ")) +} diff --git a/cmd/dispatch/config_test.go b/cmd/dispatch/config_test.go new file mode 100644 index 0000000..2b762db --- /dev/null +++ b/cmd/dispatch/config_test.go @@ -0,0 +1,319 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "regexp" + "strings" + "testing" + + "github.com/jongio/dispatch/internal/config" +) + +// collapseSpaces rewrites runs of spaces to a single space so tests can assert +// on aligned "key = value" output without hard-coding column widths. +func collapseSpaces(s string) string { + return regexp.MustCompile(` +`).ReplaceAllString(s, " ") +} + +// withConfigSeams substitutes the config load/save/path seams for a test and +// returns a pointer to the in-memory config that set operations mutate. +func withConfigSeams(t *testing.T, initial *config.Config) *config.Config { + t.Helper() + if initial == nil { + initial = config.Default() + } + prevLoad, prevSave, prevPath := configLoadFn, configSaveFn, configPathFn + configLoadFn = func() (*config.Config, error) { return initial, nil } + configSaveFn = func(c *config.Config) error { initial = c; return nil } + configPathFn = func() (string, error) { return "/tmp/dispatch/config.json", nil } + t.Cleanup(func() { + configLoadFn, configSaveFn, configPathFn = prevLoad, prevSave, prevPath + }) + return initial +} + +func TestRunConfigList_Text(t *testing.T) { + cfg := config.Default() + cfg.DefaultShell = "pwsh" + withConfigSeams(t, cfg) + + var buf bytes.Buffer + if err := runConfig(&buf, []string{"config", "list"}); err != nil { + t.Fatalf("runConfig list: %v", err) + } + out := collapseSpaces(buf.String()) + if !strings.Contains(out, "default_shell = pwsh") { + t.Errorf("list output missing default_shell line:\n%s", out) + } + if !strings.Contains(out, "show_preview = true") { + t.Errorf("list output missing show_preview line:\n%s", out) + } +} + +func TestRunConfigList_DefaultsToList(t *testing.T) { + withConfigSeams(t, config.Default()) + + var buf bytes.Buffer + if err := runConfig(&buf, []string{"config"}); err != nil { + t.Fatalf("runConfig with no subcommand: %v", err) + } + if !strings.Contains(collapseSpaces(buf.String()), "max_sessions = 100") { + t.Errorf("bare config should list settings, got:\n%s", buf.String()) + } +} + +func TestRunConfigList_JSON(t *testing.T) { + cfg := config.Default() + cfg.MaxSessions = 42 + cfg.AISearch = true + withConfigSeams(t, cfg) + + var buf bytes.Buffer + if err := runConfig(&buf, []string{"config", "list", "--json"}); err != nil { + t.Fatalf("runConfig list --json: %v", err) + } + + var obj map[string]any + if err := json.Unmarshal(buf.Bytes(), &obj); err != nil { + t.Fatalf("output is not valid JSON: %v\n%s", err, buf.String()) + } + if got, ok := obj["max_sessions"].(float64); !ok || int(got) != 42 { + t.Errorf("max_sessions = %v, want 42", obj["max_sessions"]) + } + if got, ok := obj["ai_search"].(bool); !ok || !got { + t.Errorf("ai_search = %v, want true", obj["ai_search"]) + } + // auto_refresh_seconds is unset by default and should serialize as null. + if v, present := obj["auto_refresh_seconds"]; !present || v != nil { + t.Errorf("auto_refresh_seconds = %v, want null", v) + } +} + +func TestRunConfigGet(t *testing.T) { + cfg := config.Default() + cfg.Theme = "dracula" + withConfigSeams(t, cfg) + + var buf bytes.Buffer + if err := runConfig(&buf, []string{"config", "get", "theme"}); err != nil { + t.Fatalf("runConfig get: %v", err) + } + if strings.TrimSpace(buf.String()) != "dracula" { + t.Errorf("get theme = %q, want dracula", buf.String()) + } +} + +func TestRunConfigGet_UnknownKey(t *testing.T) { + withConfigSeams(t, config.Default()) + + err := runConfig(&bytes.Buffer{}, []string{"config", "get", "nope"}) + if err == nil { + t.Fatal("expected error for unknown key") + } + if !strings.Contains(err.Error(), "unknown config key") { + t.Errorf("error = %v, want unknown config key", err) + } +} + +func TestRunConfigGet_RequiresKey(t *testing.T) { + withConfigSeams(t, config.Default()) + if err := runConfig(&bytes.Buffer{}, []string{"config", "get"}); err == nil { + t.Fatal("expected error when get has no key") + } +} + +func TestRunConfigSet_String(t *testing.T) { + cfg := withConfigSeams(t, config.Default()) + + var buf bytes.Buffer + if err := runConfig(&buf, []string{"config", "set", "agent", "gpt-5"}); err != nil { + t.Fatalf("runConfig set: %v", err) + } + if cfg.Agent != "gpt-5" { + t.Errorf("Agent = %q, want gpt-5", cfg.Agent) + } + if !strings.Contains(buf.String(), "agent = gpt-5") { + t.Errorf("set output = %q, want confirmation", buf.String()) + } +} + +func TestRunConfigSet_Bool(t *testing.T) { + cfg := withConfigSeams(t, config.Default()) + + if err := runConfig(&bytes.Buffer{}, []string{"config", "set", "show_preview", "false"}); err != nil { + t.Fatalf("runConfig set bool: %v", err) + } + if cfg.ShowPreview { + t.Error("ShowPreview = true, want false after set") + } +} + +func TestRunConfigSet_BoolInvalid(t *testing.T) { + withConfigSeams(t, config.Default()) + err := runConfig(&bytes.Buffer{}, []string{"config", "set", "show_preview", "maybe"}) + if err == nil || !strings.Contains(err.Error(), "true or false") { + t.Errorf("error = %v, want true/false guidance", err) + } +} + +func TestRunConfigSet_Int(t *testing.T) { + cfg := withConfigSeams(t, config.Default()) + + if err := runConfig(&bytes.Buffer{}, []string{"config", "set", "max_sessions", "250"}); err != nil { + t.Fatalf("runConfig set int: %v", err) + } + if cfg.MaxSessions != 250 { + t.Errorf("MaxSessions = %d, want 250", cfg.MaxSessions) + } +} + +func TestRunConfigSet_IntNegativeRejected(t *testing.T) { + withConfigSeams(t, config.Default()) + if err := runConfig(&bytes.Buffer{}, []string{"config", "set", "max_sessions", "-1"}); err == nil { + t.Fatal("expected error for negative max_sessions") + } +} + +func TestRunConfigSet_EnumValid(t *testing.T) { + cfg := withConfigSeams(t, config.Default()) + + if err := runConfig(&bytes.Buffer{}, []string{"config", "set", "launch_mode", "window"}); err != nil { + t.Fatalf("runConfig set enum: %v", err) + } + if cfg.LaunchMode != config.LaunchModeWindow { + t.Errorf("LaunchMode = %q, want window", cfg.LaunchMode) + } +} + +func TestRunConfigSet_EnumInvalid(t *testing.T) { + withConfigSeams(t, config.Default()) + err := runConfig(&bytes.Buffer{}, []string{"config", "set", "launch_mode", "hologram"}) + if err == nil || !strings.Contains(err.Error(), "invalid value") { + t.Errorf("error = %v, want invalid value", err) + } +} + +func TestRunConfigSet_Duration(t *testing.T) { + cfg := withConfigSeams(t, config.Default()) + + if err := runConfig(&bytes.Buffer{}, []string{"config", "set", "attention_threshold", "30m"}); err != nil { + t.Fatalf("runConfig set duration: %v", err) + } + if cfg.AttentionThreshold != "30m" { + t.Errorf("AttentionThreshold = %q, want 30m", cfg.AttentionThreshold) + } + + if err := runConfig(&bytes.Buffer{}, []string{"config", "set", "attention_threshold", "soon"}); err == nil { + t.Fatal("expected error for invalid duration") + } +} + +func TestRunConfigSet_AutoRefreshDefaultUnsets(t *testing.T) { + cfg := config.Default() + n := 10 + cfg.AutoRefreshSeconds = &n + cfg = withConfigSeams(t, cfg) + + if err := runConfig(&bytes.Buffer{}, []string{"config", "set", "auto_refresh_seconds", "default"}); err != nil { + t.Fatalf("runConfig set auto_refresh_seconds default: %v", err) + } + if cfg.AutoRefreshSeconds != nil { + t.Errorf("AutoRefreshSeconds = %v, want nil after default", *cfg.AutoRefreshSeconds) + } +} + +func TestRunConfigSet_AutoRefreshNumber(t *testing.T) { + cfg := withConfigSeams(t, config.Default()) + + if err := runConfig(&bytes.Buffer{}, []string{"config", "set", "auto_refresh_seconds", "0"}); err != nil { + t.Fatalf("runConfig set auto_refresh_seconds 0: %v", err) + } + if cfg.AutoRefreshSeconds == nil || *cfg.AutoRefreshSeconds != 0 { + t.Errorf("AutoRefreshSeconds = %v, want 0", cfg.AutoRefreshSeconds) + } +} + +func TestRunConfigSet_UnknownKey(t *testing.T) { + withConfigSeams(t, config.Default()) + if err := runConfig(&bytes.Buffer{}, []string{"config", "set", "nope", "1"}); err == nil { + t.Fatal("expected error for unknown key on set") + } +} + +func TestRunConfigSet_RequiresKeyAndValue(t *testing.T) { + withConfigSeams(t, config.Default()) + if err := runConfig(&bytes.Buffer{}, []string{"config", "set", "agent"}); err == nil { + t.Fatal("expected error when set is missing a value") + } +} + +func TestRunConfigSet_SaveError(t *testing.T) { + withConfigSeams(t, config.Default()) + configSaveFn = func(*config.Config) error { return errors.New("disk full") } + + err := runConfig(&bytes.Buffer{}, []string{"config", "set", "agent", "x"}) + if err == nil || !strings.Contains(err.Error(), "disk full") { + t.Errorf("error = %v, want save failure", err) + } +} + +func TestRunConfigPath(t *testing.T) { + withConfigSeams(t, config.Default()) + + var buf bytes.Buffer + if err := runConfig(&buf, []string{"config", "path"}); err != nil { + t.Fatalf("runConfig path: %v", err) + } + if strings.TrimSpace(buf.String()) != "/tmp/dispatch/config.json" { + t.Errorf("path = %q, want config.json path", buf.String()) + } +} + +func TestRunConfig_UnknownSubcommand(t *testing.T) { + withConfigSeams(t, config.Default()) + err := runConfig(&bytes.Buffer{}, []string{"config", "frobnicate"}) + if err == nil || !strings.Contains(err.Error(), "unknown config subcommand") { + t.Errorf("error = %v, want unknown subcommand", err) + } +} + +func TestConfigFields_RoundTrip(t *testing.T) { + // Every field's get must return a value its own set accepts, so a + // list -> set loop never rejects a value dispatch itself produced. + cfg := config.Default() + for _, f := range configFields() { + val := f.get(cfg) + if err := f.set(cfg, val); err != nil { + t.Errorf("field %q: set(get()) rejected %q: %v", f.name, val, err) + } + } +} + +func TestHandleArgs_Config(t *testing.T) { + withConfigSeams(t, config.Default()) + + done, cleanup, _, err := handleArgs([]string{"config", "list"}, &bytes.Buffer{}, nil) + if !done { + t.Error("expected done=true for config") + } + if cleanup != nil { + t.Error("expected cleanup=nil for config") + } + if err != nil { + t.Errorf("unexpected error: %v", err) + } +} + +func TestHandleArgs_ConfigError(t *testing.T) { + withConfigSeams(t, config.Default()) + + done, _, _, err := handleArgs([]string{"config", "get", "bogus"}, &bytes.Buffer{}, nil) + if !done { + t.Error("expected done=true for config error") + } + if err == nil { + t.Error("expected error for unknown config key") + } +} diff --git a/cmd/dispatch/main.go b/cmd/dispatch/main.go index 6eb4ae0..d1995a4 100644 --- a/cmd/dispatch/main.go +++ b/cmd/dispatch/main.go @@ -96,6 +96,8 @@ Commands: completion Print shell completion (bash, zsh, powershell) doctor [--json] Print environment diagnostics (--json for machine-readable output) stats [flags] Print session totals and breakdowns + config [get|set|list|path] + Read or change preferences (see Config commands) update Update dispatch to the latest release Stats flags: @@ -107,6 +109,12 @@ Stats flags: --since Only count sessions created on or after a date --until Only count sessions created on or before a date +Config commands: + config list [--json] Print every setting and its value + config get Print one setting value + config set Validate and save one setting + config path Print the config file path + Flags: -h, --help Show this help message -v, --version Print the version