From 1525d821fc91b14b57acb5b00ab1033c327d5c2f Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Sun, 12 Jul 2026 11:20:16 -0700 Subject: [PATCH] feat(config): honor DISPATCH_CONFIG to override config file path Set DISPATCH_CONFIG to an absolute file path to point dispatch at a different config file, which makes it easy to keep separate profiles without editing JSON by hand. The override flows through configPath(), so config path/get/set/edit and doctor all follow it. A relative or UNC value is ignored and the default OS config location is used, matching the guard already applied to the log file path. Closes #295 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 3 ++ internal/config/config.go | 25 +++++++++- internal/config/config_test.go | 91 ++++++++++++++++++++++++++++++++++ 3 files changed, 118 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index eaec257..ebcd0ee 100644 --- a/README.md +++ b/README.md @@ -429,6 +429,8 @@ 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. + ### From the command line Read and change settings without opening the TUI or editing JSON by hand: @@ -625,6 +627,7 @@ Unknown flags print an error message with usage help and exit with code 1. | Variable | Description | |---|---| +| `DISPATCH_CONFIG` | Override the path to the config file. Must be an absolute, non-UNC path; a relative or UNC value is ignored and the default location is used | | `DISPATCH_DB` | Override the path to the Copilot CLI session store database | | `DISPATCH_LOG` | Path to a log file (enables debug logging) | | `DISPATCH_NO_UPDATE_CHECK` | Skip the background release check when set to `1`, `true`, `yes`, or `on` | diff --git a/internal/config/config.go b/internal/config/config.go index 12915bd..729e33b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -531,8 +531,14 @@ func Default() *Config { } } -// configPath returns the full path to the configuration file. +// configPath returns the full path to the configuration file. When the +// DISPATCH_CONFIG environment variable holds an absolute, non-UNC path, that +// path is used instead of the default OS config location. A relative or UNC +// value is ignored so a bad override falls back to the default. func configPath() (string, error) { + if override := configPathOverride(); override != "" { + return override, nil + } dir, err := platform.ConfigDir() if err != nil { return "", err @@ -540,6 +546,23 @@ func configPath() (string, error) { return filepath.Join(dir, configFileName), nil } +// configPathOverride returns a validated config path from the DISPATCH_CONFIG +// environment variable, or an empty string when the variable is unset or names +// a relative or UNC path. This mirrors the guard used for the CLI log file so +// the override cannot point at a network location or a path relative to the +// current directory. +func configPathOverride() string { + raw := os.Getenv("DISPATCH_CONFIG") + if raw == "" { + return "" + } + cleaned := filepath.Clean(raw) + if !filepath.IsAbs(cleaned) || strings.HasPrefix(cleaned, `\\`) { + return "" + } + return cleaned +} + // ConfigPath returns the full path to the configuration file. func ConfigPath() (string, error) { return configPath() diff --git a/internal/config/config_test.go b/internal/config/config_test.go index fd79615..5938808 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -722,6 +722,97 @@ func TestConfigPathFormat(t *testing.T) { } } +// --------------------------------------------------------------------------- +// DISPATCH_CONFIG path override +// --------------------------------------------------------------------------- + +func TestConfigPathHonorsDispatchConfigOverride(t *testing.T) { + withTempConfigDir(t) + custom := filepath.Join(t.TempDir(), "profile.json") + t.Setenv("DISPATCH_CONFIG", custom) + + path, err := configPath() + if err != nil { + t.Fatalf("configPath: %v", err) + } + if path != custom { + t.Errorf("configPath = %q, want override %q", path, custom) + } + pub, err := ConfigPath() + if err != nil { + t.Fatalf("ConfigPath: %v", err) + } + if pub != custom { + t.Errorf("ConfigPath = %q, want override %q", pub, custom) + } +} + +func TestSaveAndLoadUseDispatchConfigOverride(t *testing.T) { + withTempConfigDir(t) + custom := filepath.Join(t.TempDir(), "profile.json") + t.Setenv("DISPATCH_CONFIG", custom) + + if err := Save(&Config{Agent: "reviewer", Model: "claude-3"}); err != nil { + t.Fatalf("Save: %v", err) + } + if _, err := os.Stat(custom); err != nil { + t.Fatalf("override file should exist after Save: %v", err) + } + + loaded, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if loaded.Agent != "reviewer" { + t.Errorf("Agent = %q, want 'reviewer'", loaded.Agent) + } + if loaded.Model != "claude-3" { + t.Errorf("Model = %q, want 'claude-3'", loaded.Model) + } +} + +func TestConfigPathIgnoresUnusableDispatchConfig(t *testing.T) { + for _, tc := range []struct { + name string + value string + }{ + {name: "relative", value: filepath.Join("nested", "profile.json")}, + {name: "unc", value: `\\server\share\profile.json`}, + } { + t.Run(tc.name, func(t *testing.T) { + withTempConfigDir(t) + t.Setenv("DISPATCH_CONFIG", tc.value) + + path, err := configPath() + if err != nil { + t.Fatalf("configPath: %v", err) + } + if filepath.Base(path) != configFileName { + t.Errorf("config file name = %q, want %q", filepath.Base(path), configFileName) + } + if filepath.Base(filepath.Dir(path)) != "dispatch" { + t.Errorf("config dir = %q, want default 'dispatch' parent", filepath.Dir(path)) + } + }) + } +} + +func TestConfigPathUnsetDispatchConfigUsesDefault(t *testing.T) { + withTempConfigDir(t) + t.Setenv("DISPATCH_CONFIG", "") + + path, err := configPath() + if err != nil { + t.Fatalf("configPath: %v", err) + } + if filepath.Base(path) != configFileName { + t.Errorf("config file name = %q, want %q", filepath.Base(path), configFileName) + } + if filepath.Base(filepath.Dir(path)) != "dispatch" { + t.Errorf("config dir = %q, want default 'dispatch' parent", filepath.Dir(path)) + } +} + func TestDefaultFieldTypes(t *testing.T) { t.Parallel() cfg := Default()