Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -667,7 +667,7 @@ Configuration is stored in the platform-specific config directory:
- **macOS**: `~/Library/Application Support/dispatch/config.json`
- **Windows**: `%APPDATA%\dispatch\config.json`

Set `DISPATCH_CONFIG` to an absolute file path to use a different config file, for example to keep separate work and personal profiles. `config path`, `config get`/`set`/`edit`, and `doctor` all follow the override. A relative or UNC value is ignored and the default location is used.
Set `DISPATCH_CONFIG` to an absolute file path to use a different config file, for example to keep separate work and personal profiles. `config path`, `config get`/`set`/`edit`/`export`/`import`, and `doctor` all follow the override. A relative or UNC value is ignored and the default location is used.

### From the command line

Expand All @@ -681,9 +681,11 @@ 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
dispatch config export --out dispatch-config.json
dispatch config import --in dispatch-config.json
```

`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.
`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. `export` prints the full JSON config to stdout, or writes it to `--out <file>`. `import` reads JSON from stdin, or from `--in <file>`, then saves it through the normal config 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

Expand Down
6 changes: 3 additions & 3 deletions cmd/dispatch/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -430,7 +430,7 @@ _dispatch_completion() {
;;
config)
if [[ "${COMP_CWORD}" -eq 2 ]]; then
COMPREPLY=( $(compgen -W "list get set unset edit path" -- "${cur}") )
COMPREPLY=( $(compgen -W "list get set unset edit path export import" -- "${cur}") )
elif [[ "${COMP_WORDS[2]}" == "get" || "${COMP_WORDS[2]}" == "set" || "${COMP_WORDS[2]}" == "unset" ]]; then
COMPREPLY=( $(compgen -W "$("${bin}" __complete config-keys)" -- "${cur}") )
fi
Expand All @@ -446,7 +446,7 @@ _dispatch_completion() {
local -a commands flags configsubs shells aliases configkeys openflags newflags
local bin=${words[1]}
commands=(help version open new doctor update completion stats search tags notes views aliases alias compare prune tag watch config export info path man)
configsubs=(list get set unset edit path)
configsubs=(list get set unset edit path export import)
openflags=(--mode --last --print --agent --model --yolo)
newflags=(--mode --agent --model --yolo)
flags=(-h --help -v --version --demo --clear-cache --reindex --current --cwd --repo --branch --query)
Expand Down Expand Up @@ -527,7 +527,7 @@ end
const powershellCompletionScript = `# PowerShell completion for dispatch
$script:DispatchCommands = @('help', 'version', 'open', 'new', 'doctor', 'update', 'completion', 'stats', 'search', 'tags', 'aliases', 'alias', 'compare', 'prune', 'tag', 'watch', 'config', 'export', 'info', 'path', 'man')
$script:DispatchFlags = @('-h', '--help', '-v', '--version', '--demo', '--clear-cache', '--reindex', '--current', '--cwd', '--repo', '--branch', '--query')
$script:DispatchConfigSubcommands = @('list', 'get', 'set', 'unset', 'edit', 'path')
$script:DispatchConfigSubcommands = @('list', 'get', 'set', 'unset', 'edit', 'path', 'export', 'import')
$script:DispatchOpenFlags = @('--mode', '--last', '--print', '--agent', '--model', '--yolo')
$script:DispatchNewFlags = @('--mode', '--agent', '--model', '--yolo')

Expand Down
84 changes: 83 additions & 1 deletion cmd/dispatch/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ var (
configLoadFn = config.Load
configSaveFn = config.Save
configPathFn = config.ConfigPath
configStdin io.Reader = os.Stdin
)

// editorLauncher opens the given file in the user's editor and waits for it to
Expand Down Expand Up @@ -257,8 +258,12 @@ func runConfig(w io.Writer, args []string) error {
return runConfigEdit(w, rest)
case "path":
return runConfigPath(w, rest)
case "export":
return runConfigExport(w, rest)
case "import":
return runConfigImport(w, rest)
default:
return fmt.Errorf("unknown config subcommand %q (want list, get, set, unset, edit, or path)", sub)
return fmt.Errorf("unknown config subcommand %q (want list, get, set, unset, edit, path, export, or import)", sub)
}
}

Expand Down Expand Up @@ -436,6 +441,83 @@ func runConfigEdit(w io.Writer, args []string) error {
return nil
}

func runConfigExport(w io.Writer, args []string) error {
outPath := ""
for i := 0; i < len(args); i++ {
switch args[i] {
case "--out":
i++
if i >= len(args) || strings.TrimSpace(args[i]) == "" {
return fmt.Errorf("config export --out requires a file path")
}
outPath = args[i]
default:
return fmt.Errorf("config export: unknown argument %q", args[i])
}
}

cfg, err := configLoadFn()
if err != nil {
return err
}
b, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return fmt.Errorf("marshalling config: %w", err)
}
b = append(b, '\n')

if outPath == "" {
_, err = w.Write(b)
return err
}
if err := os.WriteFile(outPath, b, 0o600); err != nil {
return fmt.Errorf("writing config export: %w", err)
}
fmt.Fprintf(w, "Exported %s\n", outPath)
return nil
}

func runConfigImport(w io.Writer, args []string) error {
inPath := ""
for i := 0; i < len(args); i++ {
switch args[i] {
case "--in":
i++
if i >= len(args) || strings.TrimSpace(args[i]) == "" {
return fmt.Errorf("config import --in requires a file path")
}
inPath = args[i]
default:
return fmt.Errorf("config import: unknown argument %q", args[i])
}
}

var r io.Reader = configStdin
if inPath != "" {
f, err := os.Open(inPath)
if err != nil {
return fmt.Errorf("opening config import: %w", err)
}
defer f.Close() //nolint:errcheck // read-only file close
r = f
}

cfg := config.Default()
dec := json.NewDecoder(r)
dec.DisallowUnknownFields()
if err := dec.Decode(cfg); err != nil {
return fmt.Errorf("parsing config import: %w", err)
}
if err := configSaveFn(cfg); err != nil {
return err
}
if _, err := configLoadFn(); err != nil {
return fmt.Errorf("validating imported config: %w", err)
}
fmt.Fprintln(w, "Imported config")
return nil
}

// resolveEditorArgv returns the editor command and any leading arguments,
// preferring $VISUAL, then $EDITOR, then a platform default. The value is split
// on whitespace so entries like "code --wait" keep their flags.
Expand Down
86 changes: 85 additions & 1 deletion cmd/dispatch/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"bytes"
"encoding/json"
"errors"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
Expand Down Expand Up @@ -74,7 +76,6 @@ func TestRunConfigList_JSON(t *testing.T) {
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())
Expand All @@ -91,6 +92,89 @@ func TestRunConfigList_JSON(t *testing.T) {
}
}

func TestRunConfigExport_Stdout(t *testing.T) {
cfg := config.Default()
cfg.Theme = "campbell"
withConfigSeams(t, cfg)

var buf bytes.Buffer
if err := runConfig(&buf, []string{"config", "export"}); err != nil {
t.Fatalf("runConfig export: %v", err)
}
var got config.Config
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
t.Fatalf("export output is not JSON: %v\n%s", err, buf.String())
}
if got.Theme != "campbell" {
t.Fatalf("exported theme = %q, want campbell", got.Theme)
}
}

func TestRunConfigExport_File(t *testing.T) {
cfg := config.Default()
cfg.Model = "gpt-5"
withConfigSeams(t, cfg)

out := filepath.Join(t.TempDir(), "dispatch-config.json")
var buf bytes.Buffer
if err := runConfig(&buf, []string{"config", "export", "--out", out}); err != nil {
t.Fatalf("runConfig export --out: %v", err)
}
b, err := os.ReadFile(out)
if err != nil {
t.Fatalf("reading export: %v", err)
}
if !strings.Contains(string(b), `"model": "gpt-5"`) {
t.Fatalf("export file missing model:\n%s", string(b))
}
if !strings.Contains(buf.String(), "Exported") {
t.Fatalf("export confirmation = %q, want Exported", buf.String())
}
}

func TestRunConfigImport_Stdin(t *testing.T) {
withConfigSeams(t, config.Default())
var imported *config.Config
configSaveFn = func(c *config.Config) error {
imported = c
return nil
}
prevStdin := configStdin
configStdin = strings.NewReader(`{"theme":"one-half-light","max_sessions":42}`)
t.Cleanup(func() { configStdin = prevStdin })

var buf bytes.Buffer
if err := runConfig(&buf, []string{"config", "import"}); err != nil {
t.Fatalf("runConfig import: %v", err)
}
if imported == nil || imported.Theme != "one-half-light" || imported.MaxSessions != 42 {
t.Fatalf("imported config = %+v, want theme one-half-light max 42", imported)
}
if !strings.Contains(buf.String(), "Imported config") {
t.Fatalf("import confirmation = %q", buf.String())
}
}

func TestRunConfigImport_File(t *testing.T) {
withConfigSeams(t, config.Default())
var imported *config.Config
configSaveFn = func(c *config.Config) error {
imported = c
return nil
}
in := filepath.Join(t.TempDir(), "dispatch-config.json")
if err := os.WriteFile(in, []byte(`{"agent":"coder","show_preview":false}`), 0o600); err != nil {
t.Fatal(err)
}

if err := runConfig(&bytes.Buffer{}, []string{"config", "import", "--in", in}); err != nil {
t.Fatalf("runConfig import --in: %v", err)
}
if imported == nil || imported.Agent != "coder" || imported.ShowPreview {
t.Fatalf("imported config = %+v, want agent coder and show_preview false", imported)
}
}

func TestRunConfigGet(t *testing.T) {
cfg := config.Default()
cfg.Theme = "dracula"
Expand Down
4 changes: 3 additions & 1 deletion cmd/dispatch/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ Commands:
alias <id> <name> Set, reassign, clear (--clear), or remove (--remove) a session alias
notes [command] List, get, set, or clear session notes
views [command] List named views or set the active view
config [get|set|list|edit|path]
config [get|set|list|edit|path|export|import]
Read or change preferences (see Config commands)
export <id> [flags] Export a session as Markdown, JSON, or HTML
export --repo R [flags] Export all sessions matching a scope filter (batch mode)
Expand Down Expand Up @@ -205,6 +205,8 @@ Config commands:
config unset <key> Reset one setting to its default
config edit Open the config file in your editor
config path Print the config file path
config export [--out F] Print config JSON or write it to a file
config import [--in F] Read config JSON from stdin or a file

Notes commands:
notes [list] [--json] List notes attached to current sessions
Expand Down
2 changes: 1 addition & 1 deletion cmd/dispatch/man.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ var manCommands = []manEntry{
{"stats [flags]", "Print session totals and breakdowns."},
{"search [query] [flags]", "Print matching sessions without the TUI."},
{"tags [--json]", "List tags in use with per-tag session counts."},
{"config <get|set|list|edit|path>", "Read or change preferences."},
{"config <get|set|list|edit|path|export|import>", "Read or change preferences."},
{"export <id> [flags]", "Export a session as Markdown or JSON."},
{"man", "Write this man page in roff format to standard output."},
{"update", "Update dispatch to the latest release."},
Expand Down
Loading