From b4070b7de6138c28edb10df970ad5915dd2e42f2 Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Sun, 12 Jul 2026 11:33:21 -0700 Subject: [PATCH] feat(config): add config unset to reset a setting to its default dispatch config could list, get, set, edit, and print the path, but the only way to clear one setting back to its default was to edit the file by hand or reset the whole config. Add config unset , which applies the value that key has in a freshly built default config and saves through the normal path, so validation and migrations still run. unset reuses each field's validated setter, so it works uniformly across scalar, enum, duration, and the pointer-backed auto_refresh_seconds field. Unknown keys and bad argument counts return the same style of error as get and set. Shell completion lists unset and the README documents it. While adding the usage line for unset, the printUsage text crossed the Windows os.Pipe buffer size, which made TestPrintUsage_Output deadlock because it drained the pipe only after printUsage returned. The test now drains the pipe from a goroutine so the write cannot block. Closes #296 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 3 +- cmd/dispatch/cli.go | 6 +-- cmd/dispatch/config.go | 36 +++++++++++++++++- cmd/dispatch/config_test.go | 73 +++++++++++++++++++++++++++++++++++++ cmd/dispatch/main.go | 1 + cmd/dispatch/main_test.go | 16 ++++++-- 6 files changed, 126 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index eaec257..eeff504 100644 --- a/README.md +++ b/README.md @@ -438,11 +438,12 @@ 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 unset launch_mode # reset one setting to its default dispatch config edit # open the config file in your editor 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. `edit` opens the file in `$VISUAL` or `$EDITOR` (falling back to a platform default) and re-checks it after you save, which is handy for list and map settings that `set` does not cover. +`set` validates the value and writes through the same save path the TUI uses, so migrations and checks still run. `unset` resets one key to its default through that same save path. 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. `edit` opens the file in `$VISUAL` or `$EDITOR` (falling back to a platform default) and re-checks it after you save, which is handy for list and map settings that `set` does not cover. ### Options diff --git a/cmd/dispatch/cli.go b/cmd/dispatch/cli.go index 813dfa9..4473800 100644 --- a/cmd/dispatch/cli.go +++ b/cmd/dispatch/cli.go @@ -302,7 +302,7 @@ _dispatch_completion() { fi if [[ "${COMP_WORDS[1]}" == "config" ]]; then - COMPREPLY=( $(compgen -W "list get set edit path" -- "${cur}") ) + COMPREPLY=( $(compgen -W "list get set unset edit path" -- "${cur}") ) return 0 fi } @@ -314,7 +314,7 @@ _dispatch_completion() { local -a commands shells flags configsubs commands=(help version open new doctor update completion stats search tags config export) shells=(bash zsh fish powershell) - configsubs=(list get set edit path) + configsubs=(list get set unset edit path) flags=(-h --help -v --version --demo --clear-cache --reindex --current --cwd --repo --branch --query) if (( CURRENT == 2 )); then @@ -358,7 +358,7 @@ const powershellCompletionScript = `# PowerShell completion for dispatch $script:DispatchCommands = @('help', 'version', 'open', 'new', 'doctor', 'update', 'completion', 'stats', 'search', 'tags', 'config', 'export') $script:DispatchFlags = @('-h', '--help', '-v', '--version', '--demo', '--clear-cache', '--reindex', '--current', '--cwd', '--repo', '--branch', '--query') $script:DispatchShells = @('bash', 'zsh', 'fish', 'powershell') -$script:DispatchConfigSubcommands = @('list', 'get', 'set', 'edit', 'path') +$script:DispatchConfigSubcommands = @('list', 'get', 'set', 'unset', 'edit', 'path') Register-ArgumentCompleter -Native -CommandName dispatch, disp -ScriptBlock { param($wordToComplete, $commandAst, $cursorPosition) diff --git a/cmd/dispatch/config.go b/cmd/dispatch/config.go index 2190dd0..99d0e8a 100644 --- a/cmd/dispatch/config.go +++ b/cmd/dispatch/config.go @@ -251,12 +251,14 @@ func runConfig(w io.Writer, args []string) error { return runConfigGet(w, rest) case "set": return runConfigSet(w, rest) + case "unset": + return runConfigUnset(w, rest) case "edit": return runConfigEdit(w, rest) case "path": return runConfigPath(w, rest) default: - return fmt.Errorf("unknown config subcommand %q (want list, get, set, edit, or path)", sub) + return fmt.Errorf("unknown config subcommand %q (want list, get, set, unset, edit, or path)", sub) } } @@ -354,6 +356,38 @@ func runConfigSet(w io.Writer, args []string) error { return nil } +// runConfigUnset resets a single setting to its default value and persists the +// change through the existing save path. It applies the value the setting has +// in a freshly built default config, reusing each field's validated set so the +// reset behaves exactly like an explicit set of the default. +func runConfigUnset(w io.Writer, args []string) error { + if len(args) == 0 { + return fmt.Errorf("config unset requires a key (see config list for keys)") + } + if len(args) > 1 { + return fmt.Errorf("config unset 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 + } + if err := field.set(cfg, field.get(config.Default())); 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 { diff --git a/cmd/dispatch/config_test.go b/cmd/dispatch/config_test.go index a46e375..c83d761 100644 --- a/cmd/dispatch/config_test.go +++ b/cmd/dispatch/config_test.go @@ -259,6 +259,79 @@ func TestRunConfigSet_SaveError(t *testing.T) { } } +func TestRunConfigUnset_ScalarResetsToDefault(t *testing.T) { + cfg := config.Default() + cfg.MaxSessions = 250 + cfg = withConfigSeams(t, cfg) + + var buf bytes.Buffer + if err := runConfig(&buf, []string{"config", "unset", "max_sessions"}); err != nil { + t.Fatalf("runConfig unset: %v", err) + } + if cfg.MaxSessions != 100 { + t.Errorf("MaxSessions = %d, want default 100 after unset", cfg.MaxSessions) + } + if !strings.Contains(buf.String(), "max_sessions = 100") { + t.Errorf("unset output = %q, want default confirmation", buf.String()) + } + + // After unset, get reports the default value. + var getBuf bytes.Buffer + if err := runConfig(&getBuf, []string{"config", "get", "max_sessions"}); err != nil { + t.Fatalf("runConfig get after unset: %v", err) + } + if strings.TrimSpace(getBuf.String()) != "100" { + t.Errorf("get after unset = %q, want 100", getBuf.String()) + } +} + +func TestRunConfigUnset_PointerFieldResetsToUnset(t *testing.T) { + cfg := config.Default() + n := 45 + cfg.AutoRefreshSeconds = &n + cfg = withConfigSeams(t, cfg) + + if err := runConfig(&bytes.Buffer{}, []string{"config", "unset", "auto_refresh_seconds"}); err != nil { + t.Fatalf("runConfig unset auto_refresh_seconds: %v", err) + } + if cfg.AutoRefreshSeconds != nil { + t.Errorf("AutoRefreshSeconds = %v, want nil after unset", *cfg.AutoRefreshSeconds) + } +} + +func TestRunConfigUnset_UnknownKey(t *testing.T) { + withConfigSeams(t, config.Default()) + err := runConfig(&bytes.Buffer{}, []string{"config", "unset", "nope"}) + if err == nil || !strings.Contains(err.Error(), "unknown config key") { + t.Errorf("error = %v, want unknown config key", err) + } +} + +func TestRunConfigUnset_RequiresKey(t *testing.T) { + withConfigSeams(t, config.Default()) + if err := runConfig(&bytes.Buffer{}, []string{"config", "unset"}); err == nil { + t.Fatal("expected error when unset is missing a key") + } +} + +func TestRunConfigUnset_TooManyArgs(t *testing.T) { + withConfigSeams(t, config.Default()) + err := runConfig(&bytes.Buffer{}, []string{"config", "unset", "agent", "model"}) + if err == nil || !strings.Contains(err.Error(), "single key") { + t.Errorf("error = %v, want single-key guidance", err) + } +} + +func TestRunConfigUnset_SaveError(t *testing.T) { + withConfigSeams(t, config.Default()) + configSaveFn = func(*config.Config) error { return errors.New("disk full") } + + err := runConfig(&bytes.Buffer{}, []string{"config", "unset", "agent"}) + 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()) diff --git a/cmd/dispatch/main.go b/cmd/dispatch/main.go index e6833e7..70464c9 100644 --- a/cmd/dispatch/main.go +++ b/cmd/dispatch/main.go @@ -156,6 +156,7 @@ 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 unset Reset one setting to its default config edit Open the config file in your editor config path Print the config file path diff --git a/cmd/dispatch/main_test.go b/cmd/dispatch/main_test.go index 08e19a2..0be4050 100644 --- a/cmd/dispatch/main_test.go +++ b/cmd/dispatch/main_test.go @@ -448,7 +448,10 @@ func TestShiftDemoTimestamps_AlreadyRecent(t *testing.T) { // --------------------------------------------------------------------------- func TestPrintUsage_Output(t *testing.T) { - // Capture stdout. + // Capture stdout. The pipe must be drained from a separate goroutine while + // printUsage writes: on Windows the pipe buffer is only a few KB and the + // usage text is larger, so draining after printUsage returned would block + // the write and deadlock. r, w, err := os.Pipe() if err != nil { t.Fatal(err) @@ -457,13 +460,18 @@ func TestPrintUsage_Output(t *testing.T) { origStdout := os.Stdout os.Stdout = w + var buf bytes.Buffer + done := make(chan struct{}) + go func() { + _, _ = io.Copy(&buf, r) + close(done) + }() + printUsage() w.Close() os.Stdout = origStdout - - var buf bytes.Buffer - _, _ = io.Copy(&buf, r) + <-done output := buf.String() for _, want := range []string{"dispatch", "help", "version", "update", "--demo"} {