// 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 Codey 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" "strings" "codey/internal/llm" "codey/internal/websearch" ) // Filename is the config's name inside Codey's config directory. const Filename = "config.json" // Dir is Codey's directory inside the user's config directory. legacyDir is // what it was called before the rename, and is still read from. const ( Dir = "codey" legacyDir = "harness" ) // 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" ) // SetupDone is what the Setup field says once the wizard has been all the way // through. It is a word rather than a bool for the same reason the approval // modes are: a file that reads "setup": false looks like something failed. const SetupDone = "done" // Auto-compaction settings, as written to the file. Spelled out for the same // reason again: an empty value has to mean "no preference", and a bool cannot // tell that apart from "off". const ( AutoCompactOn = "on" AutoCompactOff = "off" ) // Web search settings, as written to the file. // // The empty string means WebSearchNone. That is deliberately the cautious // reading rather than the convenient one: every other capability here stays // inside the working tree, and a config written before this setting existed // must not quietly start sending the model's words to a third party. const ( // WebSearchNone offers the agent no way to reach the network. WebSearchNone = "none" // WebSearchClaude declares the API's own hosted web search tool, which // Anthropic runs on its side of the wire. Nothing is installed locally and // no extra credential is needed, but the endpoint has to offer it — a // gateway or a local server speaking the Messages API generally will not. WebSearchClaude = "claude" // WebSearchCustom runs a search command on this machine, ddgs by default, // and fetches pages over plain HTTP. It works against any endpoint, // including none, and every call is an ordinary tool call the user can see // and refuse. WebSearchCustom = "custom" ) // WebSearchModes are the settings the wizard offers, in the order it offers // them: least capable and least surprising first. var WebSearchModes = []string{WebSearchNone, WebSearchClaude, WebSearchCustom} // DefaultSearchCommand is the command WebSearchCustom runs when nothing else // is configured: ddgs, the DuckDuckGo CLI, which needs no account and no API // key. {{query}} and {{count}} are substituted as whole argv elements, never // through a shell. // // It is an alias rather than a second copy of the string. The two packages // each held their own for about an hour, which was exactly long enough for // them to disagree the first time the command had to change. const DefaultSearchCommand = websearch.DefaultCommand // 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"` // WebSearch is one of WebSearchModes. Empty means WebSearchNone. WebSearch string `json:"web_search,omitempty"` // SearchCommand is the command WebSearchCustom runs, with {{query}} and // {{count}} standing in for the search and how many results are wanted. // Empty means DefaultSearchCommand, so someone who never edits it picks up // a better default if the shipped one changes. SearchCommand string `json:"search_command,omitempty"` // Setup is SetupDone once the questions have been answered to the end. // // It is what decides whether the wizard opens by itself, in place of the // older test of whether the file exists at all. The difference shows up on // a config that exists but was never finished — written by hand, or by a // build that had fewer questions in it — where "the file is there" would // say the setup was done and this says who says so. Setup string `json:"setup,omitempty"` } // WebSearchMode is the setting in force, resolving the empty string to // WebSearchNone so callers never have to spell that reading out themselves. func (c Config) WebSearchMode() string { if !ValidWebSearch(c.WebSearch) { return WebSearchNone } return c.WebSearch } // SearchCommandLine is the command WebSearchCustom should run: what was // configured, else the shipped default. func (c Config) SearchCommandLine() string { return Or(c.SearchCommand, DefaultSearchCommand) } // ValidWebSearch reports whether mode is one this build offers. func ValidWebSearch(mode string) bool { return contains(WebSearchModes, mode) } // 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 } // Done reports whether the setup has been completed. It is the only thing // that suppresses the wizard on startup. func (c Config) Done() bool { return c.Setup == SetupDone } // Path is where the config lives: $XDG_CONFIG_HOME/codey/config.json, or // ~/.config/codey/config.json when XDG_CONFIG_HOME is unset. func Path() (string, error) { return pathIn(Dir) } // LegacyPath is where the config lived when Codey was called harness. It is // read when there is nothing at Path, and never written. func LegacyPath() (string, error) { return pathIn(legacyDir) } func pathIn(name string) (string, error) { dir, err := dirIn(name) if err != nil { return "", err } return filepath.Join(dir, Filename), nil } func dirIn(name string) (string, error) { dir, err := os.UserConfigDir() if err != nil { return "", fmt.Errorf("config: no config directory: %w", err) } return filepath.Join(dir, name), nil } // WorktreesSubdir and ScriptFilename name what /worktree keeps beside the // config: the directory the trees are made in, and the script that makes them. const ( WorktreesSubdir = "worktrees" ScriptFilename = "worktree.sh" ) // WorktreesDir is where /worktree puts the trees it creates: // ~/.config/codey/worktrees, or the XDG equivalent. It resolves through the // same config directory as Path, so the two cannot end up in different places. func WorktreesDir() (string, error) { dir, err := dirIn(Dir) if err != nil { return "", err } return filepath.Join(dir, WorktreesSubdir), nil } // ScriptPath is where the worktree script lives. It is written once with a // default and then belongs to the user: /worktree never overwrites it, so a // script taught to copy .env files or install dependencies keeps doing so. func ScriptPath() (string, error) { dir, err := dirIn(Dir) if err != nil { return "", err } return filepath.Join(dir, ScriptFilename), 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. // // With nothing at Path, the pre-rename location is tried before giving up, so // a setup done under the old name is not thrown away and the wizard does not // reappear for someone who has already answered it. The fallback is a read: // the old file stays where it is, and the next Save writes only to Path. 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) { legacy, lerr := LegacyPath() if lerr != nil { return Config{}, false, lerr } path = legacy 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 Codey, 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 = "" } // An unrecognised mode falls back to no web access at all rather than to // whichever mode happens to sort first: this is the one setting where the // wrong default sends data off the machine. if !ValidWebSearch(c.WebSearch) { c.WebSearch = "" } c.SearchCommand = strings.TrimSpace(c.SearchCommand) // Anything other than the one word means the marker was not written by a // wizard that finished, so it does not count as one. if c.Setup != SetupDone { c.Setup = "" } 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 "" }