// 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"
)
// Auto-compaction settings, as written to the file. Spelled out rather than
// stored as a bool for the same reason the approval modes are: an empty value
// has to mean "no preference", and a bool cannot tell that apart from "off".
const (
AutoCompactOn = "on"
AutoCompactOff = "off"
)
// 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"`
// AutoCompact is AutoCompactOn or AutoCompactOff. Empty means on: a
// config written before this setting existed must not quietly start
// stopping the agent at a full window.
AutoCompact string `json:"auto_compact,omitempty"`
}
// AutoCompactEnabled reports whether the conversation should fold itself when
// the window fills. Only an explicit "off" turns it off.
func (c Config) AutoCompactEnabled() bool { return c.AutoCompact != AutoCompactOff }
// 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 = ""
}
if c.AutoCompact != AutoCompactOn && c.AutoCompact != AutoCompactOff {
c.AutoCompact = ""
}
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 ""
}