// 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" "maps" "os" "path/filepath" "reflect" "slices" "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} // What to do about the skills Claude Code has on this machine — its own, and // the ones its plugins ship. // // The empty string means ClaudeSkillsOff, and for the same reason the empty // web-search mode means none: a skill is a page of instructions the model // follows, so it gets loaded because someone said so, not because a directory // happened to exist in the home directory. const ( // ClaudeSkillsOff leaves them alone. Codey reads only its own roots. ClaudeSkillsOff = "off" // ClaudeSkillsUse reads them where they lie, on every run, so an // updated plugin is an updated skill with nothing to re-run. ClaudeSkillsUse = "use" // ClaudeSkillsCopy copies them once into ~/.agents/skills, where they // become ordinary personal skills: yours to edit, and unaffected by the // next plugin update. Nothing is read from Claude's directories after // that, which is why this behaves like "off" at startup. ClaudeSkillsCopy = "copy" ) // ClaudeSkillsModes are the settings the wizard offers, in the order it offers // them: least surprising first, as with the web-search modes. var ClaudeSkillsModes = []string{ClaudeSkillsOff, ClaudeSkillsUse, ClaudeSkillsCopy} // Diagnostics settings, as written to the file. // // Empty means DiagnosticsOn. This is the one default that leans the other way // from web search, and deliberately: a diagnostic is produced by a tool // already installed on this machine, reading a file the agent has just // written. Nothing leaves the machine, and an agent that is not told it broke // the build finds out three tool calls later, if at all. const ( DiagnosticsOn = "on" DiagnosticsOff = "off" ) // 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"` // ClaudeSkills is one of ClaudeSkillsModes. Empty means // ClaudeSkillsOff. ClaudeSkills string `json:"claude_skills,omitempty"` // Diagnostics is DiagnosticsOn or DiagnosticsOff: whether a file the // agent edits is handed to whatever language server or linter this // machine has for it, and what they say handed back. Empty means on. Diagnostics string `json:"diagnostics,omitempty"` // Hooks maps a lifecycle event name to the shell commands run when it // happens; the names are the ones in hooks.All. It is the one setting // here the wizard never asks about and never writes: a hook is a command // this machine runs on your behalf, so it gets into the file because you // typed it there. Hooks map[string][]string `json:"hooks,omitempty"` // MCPServers maps a server name to how to start it. The name is what the // server's tools are filed under — mcp____ — so it is chosen // by the user, not read from the server, and stays stable when the server // is upgraded. Like Hooks it is never written by the wizard: each entry is // a program this machine runs with your environment, so it gets into the // file because you typed it there. MCPServers map[string]MCPServer `json:"mcp_servers,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"` } // MCPServer is one MCP server: a program that speaks the protocol on its // stdin and stdout. It is the same shape Claude Code and Codex use for the // same thing, so an entry can be carried across from either without being // rewritten. type MCPServer struct { // Command is the program to run, looked up on PATH if it is not a path. Command string `json:"command"` Args []string `json:"args,omitempty"` // Env is added to Codey's own environment for this server only; it is // where an API key the server needs goes. Env map[string]string `json:"env,omitempty"` } // Equal reports whether two configs say the same thing. // // It exists because Config stopped being comparable with == when hooks // arrived: one map field makes the whole struct uncomparable, and the settings // panel decides whether to rewrite the file by asking whether anything // changed. Spelling the comparison out is the price of the field, and it is a // cheaper price than a settings panel that saves on every close. func (c Config) Equal(other Config) bool { // The copies are local, so the fields that need a rule of their own are // lifted out and everything else compared wholesale. Reflection rather // than a list of field names: a list is a thing to forget when the next // setting arrives, and this runs once, when a settings panel closes. mine, theirs := c.Hooks, other.Hooks myServers, theirServers := c.MCPServers, other.MCPServers c.Hooks, other.Hooks = nil, nil c.MCPServers, other.MCPServers = nil, nil return reflect.DeepEqual(c, other) && hooksEqual(mine, theirs) && mcpServersEqual(myServers, theirServers) } // mcpServersEqual compares two server maps, treating a missing map and an // empty one as the same thing, and a server with no args and one with an // empty args list likewise. Both distinctions are invisible in the file once // it has been saved, so a panel that saw one would be rewriting the file to // say what it already says. func mcpServersEqual(a, b map[string]MCPServer) bool { if len(a) != len(b) { return false } for name, as := range a { bs, ok := b[name] if !ok || as.Command != bs.Command || !slices.Equal(as.Args, bs.Args) || !maps.Equal(as.Env, bs.Env) { return false } } return true } // hooksEqual compares two hook maps, treating a missing event and an empty // list of commands for it as the same thing. func hooksEqual(a, b map[string][]string) bool { count := func(m map[string][]string) int { n := 0 for _, v := range m { if len(v) > 0 { n++ } } return n } if count(a) != count(b) { return false } for k, av := range a { if len(av) == 0 { continue } if !slices.Equal(av, b[k]) { return false } } return true } // 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) } // ClaudeSkillsMode is the setting in force, resolving the empty string to // ClaudeSkillsOff. func (c Config) ClaudeSkillsMode() string { if !ValidClaudeSkills(c.ClaudeSkills) { return ClaudeSkillsOff } return c.ClaudeSkills } // ValidClaudeSkills reports whether mode is one this build offers. func ValidClaudeSkills(mode string) bool { return contains(ClaudeSkillsModes, mode) } // DiagnosticsEnabled reports whether an edited file should be checked by // whatever language server or linter is installed for it. Only an explicit // "off" turns it off. func (c Config) DiagnosticsEnabled() bool { return c.Diagnostics != DiagnosticsOff } // 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 = "" } // Same reading of the same kind of doubt: an unrecognised word here // leaves Claude's skills unread rather than loading instructions nobody // in this session asked for. if !ValidClaudeSkills(c.ClaudeSkills) { c.ClaudeSkills = "" } if c.Diagnostics != DiagnosticsOn && c.Diagnostics != DiagnosticsOff { c.Diagnostics = "" } 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 "" }