diff --git a/README.md b/README.md index d5c94e6..6d2a8b7 100644 --- a/README.md +++ b/README.md @@ -443,11 +443,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 efbbf69..a540faf 100644 --- a/cmd/dispatch/cli.go +++ b/cmd/dispatch/cli.go @@ -338,7 +338,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 } @@ -350,7 +350,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 @@ -394,7 +394,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 0638951..7ed63aa 100644 --- a/cmd/dispatch/main.go +++ b/cmd/dispatch/main.go @@ -157,6 +157,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 c0b63e9..6b8add5 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)