Skip to content

Commit 244f4d0

Browse files
authored
Add config export and import (#363)
* Add config export and import Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 340b4dd3-b4b3-4f1a-9163-66b16d96fa81 * style: fix gofmt alignment and omit redundant io.Reader type Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1f9c4d25-0d71-40ed-ae4c-4e703a9b4291 --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 340b4dd3-b4b3-4f1a-9163-66b16d96fa81 Copilot-Session: 1f9c4d25-0d71-40ed-ae4c-4e703a9b4291
1 parent ef0e242 commit 244f4d0

6 files changed

Lines changed: 182 additions & 12 deletions

File tree

‎README.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -667,7 +667,7 @@ Configuration is stored in the platform-specific config directory:
667667
- **macOS**: `~/Library/Application Support/dispatch/config.json`
668668
- **Windows**: `%APPDATA%\dispatch\config.json`
669669

670-
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.
670+
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.
671671

672672
### From the command line
673673

@@ -681,9 +681,11 @@ dispatch config set launch_mode window
681681
dispatch config unset launch_mode # reset one setting to its default
682682
dispatch config edit # open the config file in your editor
683683
dispatch config path # print the config file path
684+
dispatch config export --out dispatch-config.json
685+
dispatch config import --in dispatch-config.json
684686
```
685687

686-
`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.
688+
`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.
687689

688690
### Options
689691

‎cmd/dispatch/cli.go‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -443,7 +443,7 @@ _dispatch_completion() {
443443
;;
444444
config)
445445
if [[ "${COMP_CWORD}" -eq 2 ]]; then
446-
COMPREPLY=( $(compgen -W "list get set unset edit path" -- "${cur}") )
446+
COMPREPLY=( $(compgen -W "list get set unset edit path export import" -- "${cur}") )
447447
elif [[ "${COMP_WORDS[2]}" == "get" || "${COMP_WORDS[2]}" == "set" || "${COMP_WORDS[2]}" == "unset" ]]; then
448448
COMPREPLY=( $(compgen -W "$("${bin}" __complete config-keys)" -- "${cur}") )
449449
fi
@@ -459,7 +459,7 @@ _dispatch_completion() {
459459
local -a commands flags configsubs shells aliases configkeys openflags newflags
460460
local bin=${words[1]}
461461
commands=(help version open new doctor update completion stats search tags notes views aliases alias compare prune tag watch config export info path man)
462-
configsubs=(list get set unset edit path)
462+
configsubs=(list get set unset edit path export import)
463463
openflags=(--mode --last --print --agent --model --yolo)
464464
newflags=(--mode --agent --model --yolo)
465465
flags=(-h --help -v --version --demo --clear-cache --reindex --current --cwd --repo --branch --query)
@@ -540,7 +540,7 @@ end
540540
const powershellCompletionScript = `# PowerShell completion for dispatch
541541
$script:DispatchCommands = @('help', 'version', 'open', 'new', 'doctor', 'update', 'completion', 'stats', 'search', 'tags', 'aliases', 'alias', 'compare', 'prune', 'tag', 'watch', 'config', 'export', 'info', 'path', 'man')
542542
$script:DispatchFlags = @('-h', '--help', '-v', '--version', '--demo', '--clear-cache', '--reindex', '--current', '--cwd', '--repo', '--branch', '--query')
543-
$script:DispatchConfigSubcommands = @('list', 'get', 'set', 'unset', 'edit', 'path')
543+
$script:DispatchConfigSubcommands = @('list', 'get', 'set', 'unset', 'edit', 'path', 'export', 'import')
544544
$script:DispatchOpenFlags = @('--mode', '--last', '--print', '--agent', '--model', '--yolo')
545545
$script:DispatchNewFlags = @('--mode', '--agent', '--model', '--yolo')
546546

‎cmd/dispatch/config.go‎

Lines changed: 86 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,10 @@ import (
2020
// the config read/write path, matching the seam pattern used elsewhere in this
2121
// package (see cli.go and stats.go).
2222
var (
23-
configLoadFn = config.Load
24-
configSaveFn = config.Save
25-
configPathFn = config.ConfigPath
23+
configLoadFn = config.Load
24+
configSaveFn = config.Save
25+
configPathFn = config.ConfigPath
26+
configStdin io.Reader = os.Stdin
2627
)
2728

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

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

444+
func runConfigExport(w io.Writer, args []string) error {
445+
outPath := ""
446+
for i := 0; i < len(args); i++ {
447+
switch args[i] {
448+
case "--out":
449+
i++
450+
if i >= len(args) || strings.TrimSpace(args[i]) == "" {
451+
return fmt.Errorf("config export --out requires a file path")
452+
}
453+
outPath = args[i]
454+
default:
455+
return fmt.Errorf("config export: unknown argument %q", args[i])
456+
}
457+
}
458+
459+
cfg, err := configLoadFn()
460+
if err != nil {
461+
return err
462+
}
463+
b, err := json.MarshalIndent(cfg, "", " ")
464+
if err != nil {
465+
return fmt.Errorf("marshalling config: %w", err)
466+
}
467+
b = append(b, '\n')
468+
469+
if outPath == "" {
470+
_, err = w.Write(b)
471+
return err
472+
}
473+
if err := os.WriteFile(outPath, b, 0o600); err != nil {
474+
return fmt.Errorf("writing config export: %w", err)
475+
}
476+
fmt.Fprintf(w, "Exported %s\n", outPath)
477+
return nil
478+
}
479+
480+
func runConfigImport(w io.Writer, args []string) error {
481+
inPath := ""
482+
for i := 0; i < len(args); i++ {
483+
switch args[i] {
484+
case "--in":
485+
i++
486+
if i >= len(args) || strings.TrimSpace(args[i]) == "" {
487+
return fmt.Errorf("config import --in requires a file path")
488+
}
489+
inPath = args[i]
490+
default:
491+
return fmt.Errorf("config import: unknown argument %q", args[i])
492+
}
493+
}
494+
495+
r := configStdin
496+
if inPath != "" {
497+
f, err := os.Open(inPath)
498+
if err != nil {
499+
return fmt.Errorf("opening config import: %w", err)
500+
}
501+
defer f.Close() //nolint:errcheck // read-only file close
502+
r = f
503+
}
504+
505+
cfg := config.Default()
506+
dec := json.NewDecoder(r)
507+
dec.DisallowUnknownFields()
508+
if err := dec.Decode(cfg); err != nil {
509+
return fmt.Errorf("parsing config import: %w", err)
510+
}
511+
if err := configSaveFn(cfg); err != nil {
512+
return err
513+
}
514+
if _, err := configLoadFn(); err != nil {
515+
return fmt.Errorf("validating imported config: %w", err)
516+
}
517+
fmt.Fprintln(w, "Imported config")
518+
return nil
519+
}
520+
439521
// resolveEditorArgv returns the editor command and any leading arguments,
440522
// preferring $VISUAL, then $EDITOR, then a platform default. The value is split
441523
// on whitespace so entries like "code --wait" keep their flags.

‎cmd/dispatch/config_test.go‎

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import (
44
"bytes"
55
"encoding/json"
66
"errors"
7+
"os"
8+
"path/filepath"
79
"regexp"
810
"strings"
911
"testing"
@@ -74,7 +76,6 @@ func TestRunConfigList_JSON(t *testing.T) {
7476
if err := runConfig(&buf, []string{"config", "list", "--json"}); err != nil {
7577
t.Fatalf("runConfig list --json: %v", err)
7678
}
77-
7879
var obj map[string]any
7980
if err := json.Unmarshal(buf.Bytes(), &obj); err != nil {
8081
t.Fatalf("output is not valid JSON: %v\n%s", err, buf.String())
@@ -91,6 +92,89 @@ func TestRunConfigList_JSON(t *testing.T) {
9192
}
9293
}
9394

95+
func TestRunConfigExport_Stdout(t *testing.T) {
96+
cfg := config.Default()
97+
cfg.Theme = "campbell"
98+
withConfigSeams(t, cfg)
99+
100+
var buf bytes.Buffer
101+
if err := runConfig(&buf, []string{"config", "export"}); err != nil {
102+
t.Fatalf("runConfig export: %v", err)
103+
}
104+
var got config.Config
105+
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
106+
t.Fatalf("export output is not JSON: %v\n%s", err, buf.String())
107+
}
108+
if got.Theme != "campbell" {
109+
t.Fatalf("exported theme = %q, want campbell", got.Theme)
110+
}
111+
}
112+
113+
func TestRunConfigExport_File(t *testing.T) {
114+
cfg := config.Default()
115+
cfg.Model = "gpt-5"
116+
withConfigSeams(t, cfg)
117+
118+
out := filepath.Join(t.TempDir(), "dispatch-config.json")
119+
var buf bytes.Buffer
120+
if err := runConfig(&buf, []string{"config", "export", "--out", out}); err != nil {
121+
t.Fatalf("runConfig export --out: %v", err)
122+
}
123+
b, err := os.ReadFile(out)
124+
if err != nil {
125+
t.Fatalf("reading export: %v", err)
126+
}
127+
if !strings.Contains(string(b), `"model": "gpt-5"`) {
128+
t.Fatalf("export file missing model:\n%s", string(b))
129+
}
130+
if !strings.Contains(buf.String(), "Exported") {
131+
t.Fatalf("export confirmation = %q, want Exported", buf.String())
132+
}
133+
}
134+
135+
func TestRunConfigImport_Stdin(t *testing.T) {
136+
withConfigSeams(t, config.Default())
137+
var imported *config.Config
138+
configSaveFn = func(c *config.Config) error {
139+
imported = c
140+
return nil
141+
}
142+
prevStdin := configStdin
143+
configStdin = strings.NewReader(`{"theme":"one-half-light","max_sessions":42}`)
144+
t.Cleanup(func() { configStdin = prevStdin })
145+
146+
var buf bytes.Buffer
147+
if err := runConfig(&buf, []string{"config", "import"}); err != nil {
148+
t.Fatalf("runConfig import: %v", err)
149+
}
150+
if imported == nil || imported.Theme != "one-half-light" || imported.MaxSessions != 42 {
151+
t.Fatalf("imported config = %+v, want theme one-half-light max 42", imported)
152+
}
153+
if !strings.Contains(buf.String(), "Imported config") {
154+
t.Fatalf("import confirmation = %q", buf.String())
155+
}
156+
}
157+
158+
func TestRunConfigImport_File(t *testing.T) {
159+
withConfigSeams(t, config.Default())
160+
var imported *config.Config
161+
configSaveFn = func(c *config.Config) error {
162+
imported = c
163+
return nil
164+
}
165+
in := filepath.Join(t.TempDir(), "dispatch-config.json")
166+
if err := os.WriteFile(in, []byte(`{"agent":"coder","show_preview":false}`), 0o600); err != nil {
167+
t.Fatal(err)
168+
}
169+
170+
if err := runConfig(&bytes.Buffer{}, []string{"config", "import", "--in", in}); err != nil {
171+
t.Fatalf("runConfig import --in: %v", err)
172+
}
173+
if imported == nil || imported.Agent != "coder" || imported.ShowPreview {
174+
t.Fatalf("imported config = %+v, want agent coder and show_preview false", imported)
175+
}
176+
}
177+
94178
func TestRunConfigGet(t *testing.T) {
95179
cfg := config.Default()
96180
cfg.Theme = "dracula"

‎cmd/dispatch/main.go‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ Commands:
132132
alias <id> <name> Set, reassign, clear (--clear), or remove (--remove) a session alias
133133
notes [command] List, get, set, or clear session notes
134134
views [command] List named views or set the active view
135-
config [get|set|list|edit|path]
135+
config [get|set|list|edit|path|export|import]
136136
Read or change preferences (see Config commands)
137137
export <id> [flags] Export a session as Markdown, JSON, or HTML
138138
export --repo R [flags] Export all sessions matching a scope filter (batch mode)
@@ -208,6 +208,8 @@ Config commands:
208208
config unset <key> Reset one setting to its default
209209
config edit Open the config file in your editor
210210
config path Print the config file path
211+
config export [--out F] Print config JSON or write it to a file
212+
config import [--in F] Read config JSON from stdin or a file
211213
212214
Notes commands:
213215
notes [list] [--json] List notes attached to current sessions

‎cmd/dispatch/man.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ var manCommands = []manEntry{
104104
{"stats [flags]", "Print session totals and breakdowns."},
105105
{"search [query] [flags]", "Print matching sessions without the TUI."},
106106
{"tags [--json]", "List tags in use with per-tag session counts."},
107-
{"config <get|set|list|edit|path>", "Read or change preferences."},
107+
{"config <get|set|list|edit|path|export|import>", "Read or change preferences."},
108108
{"export <id> [flags]", "Export a session as Markdown or JSON."},
109109
{"man", "Write this man page in roff format to standard output."},
110110
{"update", "Update dispatch to the latest release."},

0 commit comments

Comments
 (0)