Skip to content

Commit c749a02

Browse files
committed
feat: add user-level config support
1 parent d3e10cf commit c749a02

6 files changed

Lines changed: 226 additions & 10 deletions

File tree

cmd/dun/main.go

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ func main() {
4141
}
4242

4343
func run(args []string, stdout io.Writer, stderr io.Writer) int {
44+
harnessModel = ""
45+
harnessModelOverrides = nil
46+
4447
if len(args) < 1 {
4548
return runCheck(args, stdout, stderr)
4649
}
@@ -98,6 +101,7 @@ REVIEW MODE:
98101
single response using the synthesis harness.
99102
100103
Options:
104+
--config Config file path (default .dun/config.yaml; also loads user config)
101105
--principles Path to principles document (default docs/helix/01-frame/principles.md)
102106
--harnesses Comma-separated list of review harnesses (default: codex,claude,gemini)
103107
--synth-harness Harness to synthesize final review (default: first harness)
@@ -111,6 +115,7 @@ CHECK MODE:
111115
dun check [options]
112116
113117
Options:
118+
--config Config file path (default .dun/config.yaml; also loads user config)
114119
--prompt Output the loop prompt for the current repo state
115120
--all Include passing checks in prompt output
116121
--format Output format: prompt, llm, json
@@ -124,6 +129,7 @@ LOOP MODE:
124129
and repeats until all checks pass or max iterations is reached.
125130
126131
Options:
132+
--config Config file path (default .dun/config.yaml; also loads user config)
127133
--harness Agent to use: codex, claude, gemini, opencode (default: from config)
128134
--model Model override for selected harness(es)
129135
--models Per-harness model overrides (e.g., codex:o3,claude:sonnet)
@@ -204,7 +210,7 @@ func runCheck(args []string, stdout io.Writer, stderr io.Writer) int {
204210

205211
fs := flag.NewFlagSet("check", flag.ContinueOnError)
206212
fs.SetOutput(stderr)
207-
configPath := fs.String("config", explicitConfig, "path to config file (default .dun/config.yaml if present)")
213+
configPath := fs.String("config", explicitConfig, "path to config file (default .dun/config.yaml if present; also loads user config)")
208214
format := fs.String("format", "prompt", "output format (prompt|llm|json)")
209215
promptOut := fs.Bool("prompt", false, "output loop prompt")
210216
allChecks := fs.Bool("all", false, "include passing checks in prompt output")
@@ -284,7 +290,7 @@ func runList(args []string, stdout io.Writer, stderr io.Writer) int {
284290
fs := flag.NewFlagSet("list", flag.ContinueOnError)
285291
fs.SetOutput(stderr)
286292
format := fs.String("format", "text", "output format (text|json)")
287-
configPath := fs.String("config", "", "path to config file (default .dun/config.yaml if present)")
293+
configPath := fs.String("config", "", "path to config file (default .dun/config.yaml if present; also loads user config)")
288294
if err := fs.Parse(args); err != nil {
289295
return dun.ExitUsageError
290296
}
@@ -319,7 +325,7 @@ func runExplain(args []string, stdout io.Writer, stderr io.Writer) int {
319325
fs := flag.NewFlagSet("explain", flag.ContinueOnError)
320326
fs.SetOutput(stderr)
321327
format := fs.String("format", "text", "output format (text|json)")
322-
configPath := fs.String("config", "", "path to config file (default .dun/config.yaml if present)")
328+
configPath := fs.String("config", "", "path to config file (default .dun/config.yaml if present; also loads user config)")
323329
if err := fs.Parse(args); err != nil {
324330
return dun.ExitUsageError
325331
}
@@ -505,7 +511,7 @@ func runLoop(args []string, stdout io.Writer, stderr io.Writer) int {
505511

506512
fs := flag.NewFlagSet("loop", flag.ContinueOnError)
507513
fs.SetOutput(stderr)
508-
configPath := fs.String("config", explicitConfig, "path to config file")
514+
configPath := fs.String("config", explicitConfig, "path to config file (default .dun/config.yaml if present; also loads user config)")
509515
harness := fs.String("harness", "", "agent harness (codex|claude|gemini|opencode); default from config")
510516
model := fs.String("model", opts.AgentModel, "model override for selected harness(es)")
511517
models := fs.String("models", "", "per-harness model overrides (e.g., codex:o3,claude:sonnet)")

cmd/dun/main_test.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,15 @@ import (
1616
"github.com/easel/dun/internal/dun"
1717
)
1818

19+
func TestMain(m *testing.M) {
20+
tmp, err := os.MkdirTemp("", "dun-user-config-")
21+
if err == nil {
22+
_ = os.Setenv("HOME", tmp)
23+
_ = os.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, "xdg"))
24+
}
25+
os.Exit(m.Run())
26+
}
27+
1928
func TestCheckUsesConfigAgentAuto(t *testing.T) {
2029
root := setupRepoFromFixture(t, "helix-alignment")
2130
agentCmd := "test-agent-cmd"

cmd/dun/review.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ func runReview(args []string, stdout io.Writer, stderr io.Writer) int {
3939

4040
fs := flag.NewFlagSet("review", flag.ContinueOnError)
4141
fs.SetOutput(stderr)
42-
configPath := fs.String("config", explicitConfig, "path to config file")
42+
configPath := fs.String("config", explicitConfig, "path to config file (default .dun/config.yaml if present; also loads user config)")
4343
principlesPath := fs.String("principles", "docs/helix/01-frame/principles.md", "path to principles document")
4444
harnessesFlag := fs.String("harnesses", "codex,claude,gemini", "comma-separated list of review harnesses")
4545
synthHarness := fs.String("synth-harness", "", "harness used to synthesize final review (default: first harness)")

docs/design/contracts/API-001-dun-cli.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ $ dun [command] [options] [arguments]
2727
- `--timeout` : Global timeout in seconds (default `600`)
2828
- `--check-timeout` : Per-check timeout in seconds (default `120`)
2929
- `--workers` : Max concurrent checks (default `min(4, CPU)`)
30-
- `--config` : Path to config file (default `.dun/config.yaml` if present)
30+
- `--config` : Path to config file (default `.dun/config.yaml` if present; also loads user config from `$XDG_CONFIG_HOME/dun/config.yaml`, `~/.config/dun/config.yaml`, or `~/.dun/config.yaml`)
3131
- `--agent-cmd` : Command to run agent checks (optional, used with `--agent-mode=auto`)
3232
- `--agent-timeout` : Agent check timeout in seconds (default `300`)
3333
- `--agent-mode` : Agent mode (`prompt` or `auto`, default `prompt`)

internal/dun/config.go

Lines changed: 94 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -86,22 +86,51 @@ func ApplyConfig(opts Options, cfg Config) Options {
8686
}
8787

8888
func LoadConfig(root string, explicitPath string) (Config, bool, error) {
89+
var merged Config
90+
loaded := false
91+
92+
userPath, err := resolveUserConfigPath()
93+
if err != nil {
94+
return Config{}, false, err
95+
}
96+
if userPath != "" {
97+
cfg, err := loadConfigFile(userPath)
98+
if err != nil {
99+
return Config{}, false, err
100+
}
101+
merged = mergeConfig(merged, cfg)
102+
loaded = true
103+
}
104+
89105
path, err := resolveConfigPath(root, explicitPath)
90106
if err != nil {
91107
return Config{}, false, err
92108
}
93-
if path == "" {
109+
if path != "" {
110+
cfg, err := loadConfigFile(path)
111+
if err != nil {
112+
return Config{}, false, err
113+
}
114+
merged = mergeConfig(merged, cfg)
115+
loaded = true
116+
}
117+
118+
if !loaded {
94119
return Config{}, false, nil
95120
}
121+
return merged, true, nil
122+
}
123+
124+
func loadConfigFile(path string) (Config, error) {
96125
raw, err := os.ReadFile(path)
97126
if err != nil {
98-
return Config{}, false, err
127+
return Config{}, err
99128
}
100129
var cfg Config
101130
if err := yaml.Unmarshal(raw, &cfg); err != nil {
102-
return Config{}, false, err
131+
return Config{}, err
103132
}
104-
return cfg, true, nil
133+
return cfg, nil
105134
}
106135

107136
func resolveConfigPath(root string, explicitPath string) (string, error) {
@@ -123,3 +152,64 @@ func resolveConfigPath(root string, explicitPath string) (string, error) {
123152
}
124153
return "", nil
125154
}
155+
156+
func resolveUserConfigPath() (string, error) {
157+
var candidates []string
158+
if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
159+
candidates = append(candidates, filepath.Join(xdg, "dun", "config.yaml"))
160+
}
161+
home, err := os.UserHomeDir()
162+
if err == nil && home != "" {
163+
candidates = append(candidates, filepath.Join(home, ".config", "dun", "config.yaml"))
164+
candidates = append(candidates, filepath.Join(home, ".dun", "config.yaml"))
165+
}
166+
167+
for _, candidate := range candidates {
168+
if _, err := os.Stat(candidate); err == nil {
169+
return candidate, nil
170+
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
171+
return "", err
172+
}
173+
}
174+
return "", nil
175+
}
176+
177+
func mergeConfig(base Config, override Config) Config {
178+
merged := base
179+
if override.Version != "" {
180+
merged.Version = override.Version
181+
}
182+
183+
if override.Agent.Cmd != "" {
184+
merged.Agent.Cmd = override.Agent.Cmd
185+
}
186+
if override.Agent.Harness != "" {
187+
merged.Agent.Harness = override.Agent.Harness
188+
}
189+
if override.Agent.Model != "" {
190+
merged.Agent.Model = override.Agent.Model
191+
}
192+
if override.Agent.TimeoutMS > 0 {
193+
merged.Agent.TimeoutMS = override.Agent.TimeoutMS
194+
}
195+
if override.Agent.Mode != "" {
196+
merged.Agent.Mode = override.Agent.Mode
197+
}
198+
if override.Agent.Automation != "" {
199+
merged.Agent.Automation = override.Agent.Automation
200+
}
201+
if len(override.Agent.Models) > 0 {
202+
if merged.Agent.Models == nil {
203+
merged.Agent.Models = make(map[string]string, len(override.Agent.Models))
204+
}
205+
for key, value := range override.Agent.Models {
206+
merged.Agent.Models[key] = value
207+
}
208+
}
209+
210+
if override.Go.CoverageThreshold > 0 {
211+
merged.Go.CoverageThreshold = override.Go.CoverageThreshold
212+
}
213+
214+
return merged
215+
}

0 commit comments

Comments
 (0)