// Package config is the handful of preferences the setup wizard writes down. // // It holds defaults, never facts: every field is something a flag or an // environment variable may override for a given run. Nothing here is required // for the harness to start, which is what lets a missing file mean "first // run" rather than "broken install". package config import ( "encoding/json" "errors" "fmt" "io/fs" "os" "path/filepath" "harness/internal/llm" ) // Filename is the config's name inside the harness config directory. const Filename = "config.json" // Version is stamped on every write. It exists so a future change of shape // has something to branch on rather than guessing from which keys are present. const Version = 1 // Approval modes, as written to the file. They are spelled out rather than // stored as a bool, because "approvals: false" reads as if approvals were // broken rather than as if they were turned off deliberately. const ( ApprovalsAsk = "ask" ApprovalsAcceptAll = "accept-all" ) // Themes are the TUI palettes, in the order the wizard offers them. The names // live here rather than in the tui package so that reading a config never // depends on a terminal front end being linked in — the eval runner has none. // The tui package maps each name to actual colours. var Themes = []string{"ember", "mint", "iris", "mono"} // Config is what the wizard saves. Every field is optional: an empty one means // "no preference", which falls through to the flag, the environment, or the // built-in default, in that order. type Config struct { Version int `json:"version"` // Model is an API model id, not a display name. Model string `json:"model,omitempty"` // Effort is one of llm.EffortLevels. Effort string `json:"effort,omitempty"` // Approvals is ApprovalsAsk or ApprovalsAcceptAll. Approvals string `json:"approvals,omitempty"` // Theme is one of Themes. Theme string `json:"theme,omitempty"` } // Path is where the config lives: $XDG_CONFIG_HOME/harness/config.json, or // ~/.config/harness/config.json when XDG_CONFIG_HOME is unset. func Path() (string, error) { dir, err := os.UserConfigDir() if err != nil { return "", fmt.Errorf("config: no config directory: %w", err) } return filepath.Join(dir, "harness", Filename), nil } // Load reads the config. The boolean reports whether a usable one was found; // a missing file is not an error, because that is exactly what the first run // looks like and the wizard is the answer to it. // // A file that exists but cannot be read or parsed comes back as an error with // found=false. The caller is expected to carry on with defaults and say so: // what it must not do is treat the file as absent, because the next Save would // then overwrite settings the user still has. func Load() (Config, bool, error) { path, err := Path() if err != nil { return Config{}, false, err } raw, err := os.ReadFile(path) if errors.Is(err, fs.ErrNotExist) { return Config{}, false, nil } if err != nil { return Config{}, false, fmt.Errorf("config: reading %s: %w", path, err) } var cfg Config if err := json.Unmarshal(raw, &cfg); err != nil { return Config{}, false, fmt.Errorf("config: %s is not valid JSON: %w", path, err) } return sanitize(cfg), true, nil } // sanitize drops values this build does not recognise. A config written by a // newer harness, or edited by hand, should degrade to the default for that // field rather than be forwarded to the API, which would reject the request // with an error pointing at nothing the user can see. func sanitize(c Config) Config { // Effort is validated against the API's own list rather than a copy of it // here: two lists of the same five strings is one list that will be wrong. if !llm.ValidEffort(c.Effort) { c.Effort = "" } if c.Approvals != ApprovalsAsk && c.Approvals != ApprovalsAcceptAll { c.Approvals = "" } if !ValidTheme(c.Theme) { c.Theme = "" } return c } // ValidTheme reports whether name is a palette this build knows. func ValidTheme(name string) bool { return contains(Themes, name) } func contains(set []string, v string) bool { for _, s := range set { if s == v { return true } } return false } // Save writes the config, creating the directory if it is not there yet. // // The write goes to a temporary file that is then renamed over the target, so // an interrupted or failed write leaves the previous config intact instead of // truncating it to nothing. func Save(c Config) error { path, err := Path() if err != nil { return err } if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return fmt.Errorf("config: creating %s: %w", filepath.Dir(path), err) } c.Version = Version body, err := json.MarshalIndent(c, "", " ") if err != nil { return fmt.Errorf("config: encoding: %w", err) } body = append(body, '\n') tmp, err := os.CreateTemp(filepath.Dir(path), Filename+".*") if err != nil { return fmt.Errorf("config: %w", err) } defer os.Remove(tmp.Name()) // no-op once the rename below succeeds if _, err := tmp.Write(body); err != nil { tmp.Close() return fmt.Errorf("config: writing %s: %w", tmp.Name(), err) } if err := tmp.Close(); err != nil { return fmt.Errorf("config: writing %s: %w", tmp.Name(), err) } if err := os.Rename(tmp.Name(), path); err != nil { return fmt.Errorf("config: replacing %s: %w", path, err) } return nil } // Or returns the first non-empty of its arguments. It is how precedence is // spelled at the call sites: Or(flag, cfg.Model) is "the flag, else what was // configured, else whatever the client defaults to". func Or(vals ...string) string { for _, v := range vals { if v != "" { return v } } return "" }