diff --git a/README.md b/README.md index a8bd1e0..cc16fd9 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 `. `import` reads JSON from stdin, or from `--in `, 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 diff --git a/cmd/dispatch/cli.go b/cmd/dispatch/cli.go index b1038cf..e524dcf 100644 --- a/cmd/dispatch/cli.go +++ b/cmd/dispatch/cli.go @@ -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 @@ -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) @@ -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') diff --git a/cmd/dispatch/config.go b/cmd/dispatch/config.go index 99d0e8a..cd13369 100644 --- a/cmd/dispatch/config.go +++ b/cmd/dispatch/config.go @@ -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 @@ -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) } } @@ -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. diff --git a/cmd/dispatch/config_test.go b/cmd/dispatch/config_test.go index c83d761..ee12200 100644 --- a/cmd/dispatch/config_test.go +++ b/cmd/dispatch/config_test.go @@ -4,6 +4,8 @@ import ( "bytes" "encoding/json" "errors" + "os" + "path/filepath" "regexp" "strings" "testing" @@ -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()) @@ -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" diff --git a/cmd/dispatch/main.go b/cmd/dispatch/main.go index d689ff5..6504706 100644 --- a/cmd/dispatch/main.go +++ b/cmd/dispatch/main.go @@ -132,7 +132,7 @@ Commands: alias 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 [flags] Export a session as Markdown, JSON, or HTML export --repo R [flags] Export all sessions matching a scope filter (batch mode) @@ -205,6 +205,8 @@ Config commands: config unset 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 diff --git a/cmd/dispatch/man.go b/cmd/dispatch/man.go index c9d7a3a..e9c9843 100644 --- a/cmd/dispatch/man.go +++ b/cmd/dispatch/man.go @@ -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 ", "Read or change preferences."}, + {"config ", "Read or change preferences."}, {"export [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."},