// 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"
"github.com/johannSo/codey/internal/llm"
"github.com/johannSo/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"
)
// Claude Code login settings, as written to the file.
//
// Empty means off, and it has to: the login in ~/.claude/.credentials.json is
// issued to Claude Code, and using it from another client is outside
// Anthropic's terms for it. Borrowing it is therefore something you ask for on
// your own machine, never something a fresh install does because a file
// happened to be there. An empty value is also how the startup path tells
// "never asked" apart from an explicit "off", which is what decides whether
// someone upgrading from the silent fallback is asked once or simply told.
const (
ClaudeCodeAuthOn = "on"
ClaudeCodeAuthOff = "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"`
// ClaudeCodeAuth is ClaudeCodeAuthOn or ClaudeCodeAuthOff: whether a run
// with no API key or token of its own may borrow Claude Code's OAuth
// login. Empty means off, and means nobody has been asked yet.
ClaudeCodeAuth string `json:"claude_code_auth,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__<name>__<tool> — 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 }
// ClaudeCodeAuthEnabled reports whether the config asks for Claude Code's
// login to be borrowed. Only an explicit "on" does: this is the one setting
// where the cautious reading is also the one Anthropic's terms require.
func (c Config) ClaudeCodeAuthEnabled() bool { return c.ClaudeCodeAuth == ClaudeCodeAuthOn }
// 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 = ""
}
// An unrecognised word is not an "on". It is dropped to empty rather than
// to "off", so the one-time question at startup still gets asked: a typo
// is not an answer.
if c.ClaudeCodeAuth != ClaudeCodeAuthOn && c.ClaudeCodeAuth != ClaudeCodeAuthOff {
c.ClaudeCodeAuth = ""
}
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 ""
}