// Package settings persists the daemon's user-editable configuration as a single // JSON file (~/.keel/settings.json), parallel to the store snapshot. It is a // leaf type package: it imports nothing else in the app so any layer may depend // on the Settings value without pulling in adapters. package settings import ( "encoding/json" "errors" "os" "path/filepath" "strconv" ) // ErrInvalidBackend marks a settings value whose ai_backend is not a known // backend. The applier returns it (wrapped) so the HTTP layer can map it to 400 // without importing the ai package. var ErrInvalidBackend = errors.New("settings: invalid ai backend") // ErrInvalidAmbientMode marks a settings value whose ambient_mode is not a // known mode. The applier returns it (wrapped) so the HTTP layer can map it to // 400 without importing the ambient package. var ErrInvalidAmbientMode = errors.New("settings: invalid ambient mode") // Ambient mode dial values. const ( AmbientOff = "off" AmbientStatus = "status" AmbientNotify = "notify" ) // ValidAmbientMode reports whether m is a known ambient mode constant. func ValidAmbientMode(m string) bool { switch m { case AmbientOff, AmbientStatus, AmbientNotify: return true } return false } // Settings is the user-editable configuration. Field names mirror the env vars // they replace: KEEL_AI_BACKEND, KEEL_MARVIN_CMD, KEEL_KNOWLEDGE_FILE, KEEL_AW_URL. type Settings struct { AIBackend string `json:"ai_backend"` MarvinCmd string `json:"marvin_cmd"` KnowledgePath string `json:"knowledge_path"` AWURL string `json:"aw_url"` AmbientMode string `json:"ambient_mode"` AmbientCadenceSecs int `json:"ambient_cadence_secs"` } // DefaultPath returns ~/.keel/settings.json. func DefaultPath() (string, error) { home, err := os.UserHomeDir() if err != nil { return "", err } return filepath.Join(home, ".keel", "settings.json"), nil } // Load reads a settings file. A missing file is an error; callers treat that as // "first run" and seed instead. func Load(path string) (Settings, error) { data, err := os.ReadFile(path) if err != nil { return Settings{}, err } var s Settings if err := json.Unmarshal(data, &s); err != nil { return Settings{}, err } return s, nil } // Save writes settings atomically (temp file + rename), creating the directory // if needed. Mirrors store.Save. func Save(path string, s Settings) error { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err } data, err := json.MarshalIndent(s, "", " ") if err != nil { return err } tmp := path + ".tmp" if err := os.WriteFile(tmp, data, 0o644); err != nil { return err } return os.Rename(tmp, path) } // SeedFromEnv builds a Settings from the KEEL_* env vars, applying // the same defaults the daemon used before the settings file existed. An unset // AI backend defaults to "claude" (matching ai.NewBackend("")). func SeedFromEnv() Settings { backend := os.Getenv("KEEL_AI_BACKEND") if backend == "" { backend = "claude" } awURL := os.Getenv("KEEL_AW_URL") if awURL == "" { awURL = "http://localhost:5600" } ambientMode := os.Getenv("KEEL_AMBIENT_MODE") if ambientMode == "" { ambientMode = AmbientNotify } ambientCadenceSecs := 300 if v := os.Getenv("KEEL_AMBIENT_CADENCE_SECS"); v != "" { if n, err := strconv.Atoi(v); err == nil && n > 0 { ambientCadenceSecs = n } } return Settings{ AIBackend: backend, MarvinCmd: os.Getenv("KEEL_MARVIN_CMD"), KnowledgePath: os.Getenv("KEEL_KNOWLEDGE_FILE"), AWURL: awURL, AmbientMode: ambientMode, AmbientCadenceSecs: ambientCadenceSecs, } }