@@ -1,67 +1,132 @@
1−// Package config manages application configuration from various sources.
2package config
1package config
5 "encoding/json"
4 "encoding/json"
9 "path/filepath"
10 "path/filepath"
12− "github.com/charmbracelet/crush/internal/llm/models"
15+ "github.com/charmbracelet/crush/internal/fur/provider"
13 "github.com/charmbracelet/crush/internal/logging"
16 "github.com/charmbracelet/crush/internal/logging"
14− "github.com/spf13/afero"
15− "github.com/spf13/viper"
18−// MCPType defines the type of MCP (Model Control Protocol) server.
21−// Supported MCP types
23− MCPStdio MCPType = "stdio"
20+ defaultDataDirectory = ".crush"
24− MCPSse MCPType = "sse"
21+ defaultLogLevel = "info"
24+ MaxTokensFallbackDefault = 4096
27−// MCPServer defines the configuration for a Model Control Protocol server.
27+var defaultContextPaths = []string{
28−type MCPServer struct {
28+ ".github/copilot-instructions.md",
29− Command string `json:"command"`
29+ ".cursorrules",
30− Env []string `json:"env"`
30+ ".cursor/rules/",
31− Args []string `json:"args"`
31+ "CLAUDE.md",
32− Type MCPType `json:"type"`
32+ "CLAUDE.local.md",
33− URL string `json:"url"`
33+ "GEMINI.md",
34− Headers map[string]string `json:"headers"`
34+ "gemini.md",
37−type AgentName string
43+type AgentID string
40− AgentCoder AgentName = "coder"
46+ AgentCoder AgentID = "coder"
41− AgentSummarizer AgentName = "summarizer"
42− AgentTask AgentName = "task"
47+ AgentTask AgentID = "task"
43− AgentTitle AgentName = "title"
46−// Agent defines configuration for different LLM models and their token limits.
47−type Agent struct {
50+type Model struct {
48− Model models.ModelID `json:"model"`
51+ ID string `json:"id"`
49− MaxTokens int64 `json:"maxTokens"`
52+ Name string `json:"model"`
53+ CostPer1MIn float64 `json:"cost_per_1m_in"`
54+ CostPer1MOut float64 `json:"cost_per_1m_out"`
55+ CostPer1MInCached float64 `json:"cost_per_1m_in_cached"`
56+ CostPer1MOutCached float64 `json:"cost_per_1m_out_cached"`
57+ ContextWindow int64 `json:"context_window"`
58+ DefaultMaxTokens int64 `json:"default_max_tokens"`
59+ CanReason bool `json:"can_reason"`
50− ReasoningEffort string `json:"reasoningEffort"` // For openai models low,medium,heigh
60+ ReasoningEffort string `json:"reasoning_effort"`
61+ SupportsImages bool `json:"supports_attachments"`
53−// Provider defines configuration for an LLM provider.
54−type Provider struct {
64+type VertexAIOptions struct {
55− APIKey string `json:"apiKey"`
65+ APIKey string `json:"api_key,omitempty"`
56− Disabled bool `json:"disabled"`
66+ Project string `json:"project,omitempty"`
67+ Location string `json:"location,omitempty"`
59−// Data defines storage configuration.
70+type ProviderConfig struct {
71+ ID provider.InferenceProvider `json:"id"`
72+ BaseURL string `json:"base_url,omitempty"`
73+ ProviderType provider.Type `json:"provider_type"`
74+ APIKey string `json:"api_key,omitempty"`
75+ Disabled bool `json:"disabled"`
76+ ExtraHeaders map[string]string `json:"extra_headers,omitempty"`
77+ // used for e.x for vertex to set the project
78+ ExtraParams map[string]string `json:"extra_params,omitempty"`
80+ DefaultLargeModel string `json:"default_large_model,omitempty"`
81+ DefaultSmallModel string `json:"default_small_model,omitempty"`
83+ Models []Model `json:"models,omitempty"`
60−type Data struct {
86+type Agent struct {
61− Directory string `json:"directory,omitempty"`
87+ ID AgentID `json:"id"`
88+ Name string `json:"name"`
89+ Description string `json:"description,omitempty"`
90+ // This is the id of the system prompt used by the agent
91+ Disabled bool `json:"disabled"`
93+ Provider provider.InferenceProvider `json:"provider"`
94+ Model string `json:"model"`
96+ // The available tools for the agent
97+ // if this is nil, all tools are available
98+ AllowedTools []string `json:"allowed_tools"`
100+ // this tells us which MCPs are available for this agent
101+ // if this is empty all mcps are available
102+ // the string array is the list of tools from the AllowedMCP the agent has available
103+ // if the string array is nil, all tools from the AllowedMCP are available
104+ AllowedMCP map[string][]string `json:"allowed_mcp"`
106+ // The list of LSPs that this agent can use
107+ // if this is nil, all LSPs are available
108+ AllowedLSP []string `json:"allowed_lsp"`
110+ // Overrides the context paths for this agent
111+ ContextPaths []string `json:"context_paths"`
117+ MCPStdio MCPType = "stdio"
118+ MCPSse MCPType = "sse"
122+ Command string `json:"command"`
123+ Env []string `json:"env"`
124+ Args []string `json:"args"`
125+ Type MCPType `json:"type"`
126+ URL string `json:"url"`
127+ Headers map[string]string `json:"headers"`
64−// LSPConfig defines configuration for Language Server Protocol integration.
65type LSPConfig struct {
130type LSPConfig struct {
66 Disabled bool `json:"enabled"`
131 Disabled bool `json:"enabled"`
67 Command string `json:"command"`
132 Command string `json:"command"`
@@ -69,98 +134,72 @@ type LSPConfig struct {
69 Options any `json:"options"`
134 Options any `json:"options"`
72−// TUIConfig defines the configuration for the Terminal User Interface.
73−type TUIConfig struct {
137+type TUIOptions struct {
74− Theme string `json:"theme,omitempty"`
138+ CompactMode bool `json:"compact_mode"`
139+ // Here we can add themes later or any TUI related options
77−// Config is the main configuration structure for the application.
78−type Config struct {
142+type Options struct {
79− Data Data `json:"data"`
143+ ContextPaths []string `json:"context_paths"`
80− WorkingDir string `json:"wd,omitempty"`
144+ TUI TUIOptions `json:"tui"`
81− MCPServers map[string]MCPServer `json:"mcpServers,omitempty"`
82− Providers map[models.InferenceProvider]Provider `json:"providers,omitempty"`
83− LSP map[string]LSPConfig `json:"lsp,omitempty"`
84− Agents map[AgentName]Agent `json:"agents,omitempty"`
85− Debug bool `json:"debug,omitempty"`
145+ Debug bool `json:"debug"`
86− DebugLSP bool `json:"debugLSP,omitempty"`
146+ DebugLSP bool `json:"debug_lsp"`
87− ContextPaths []string `json:"contextPaths,omitempty"`
147+ DisableAutoSummarize bool `json:"disable_auto_summarize"`
88− TUI TUIConfig `json:"tui"`
148+ // Relative to the cwd
89− AutoCompact bool `json:"autoCompact,omitempty"`
149+ DataDirectory string `json:"data_directory"`
92−// Application constants
152+type PreferredModel struct {
93−const (
153+ ModelID string `json:"model_id"`
94− defaultDataDirectory = ".crush"
154+ Provider provider.InferenceProvider `json:"provider"`
95− defaultLogLevel = "info"
155+}
98− MaxTokensFallbackDefault = 4096
101−var defaultContextPaths = []string{
157+type PreferredModels struct {
102− ".github/copilot-instructions.md",
158+ Large PreferredModel `json:"large"`
103− ".cursorrules",
159+ Small PreferredModel `json:"small"`
117−// Global configuration instance
162+type Config struct {
118−var cfg *Config
163+ Models PreferredModels `json:"models"`
164+ // List of configured providers
165+ Providers map[provider.InferenceProvider]ProviderConfig `json:"providers,omitempty"`
120−// Load initializes the configuration from environment variables and config files.
167+ // List of configured agents
121−// If debug is true, debug mode is enabled and log level is set to debug.
168+ Agents map[AgentID]Agent `json:"agents,omitempty"`
122−// It returns an error if configuration loading fails.
123−func Load(workingDir string, debug bool) (*Config, error) {
128− cfg = &Config{
170+ // List of configured MCPs
129− WorkingDir: workingDir,
171+ MCP map[string]MCP `json:"mcp,omitempty"`
130− MCPServers: make(map[string]MCPServer),
131− Providers: make(map[models.InferenceProvider]Provider),
132− LSP: make(map[string]LSPConfig),
135− configureViper()
173+ // List of configured LSPs
136− setDefaults(debug)
174+ LSP map[string]LSPConfig `json:"lsp,omitempty"`
138− // Read global config
176+ // Miscellaneous options
139− if err := readConfig(viper.ReadInConfig()); err != nil {
177+ Options Options `json:"options"`
143− // Load and merge local config
180+var (
144− mergeLocalConfig(workingDir)
181+ instance *Config // The single instance of the Singleton
183+ once sync.Once // Ensures the initialization happens only once
146− setProviderDefaults()
185+)
148− // Apply configuration to the struct
187+func loadConfig(cwd string, debug bool) (*Config, error) {
149− if err := viper.Unmarshal(cfg); err != nil {
188+ // First read the global config file
150− return cfg, fmt.Errorf("failed to unmarshal config: %w", err)
189+ cfgPath := ConfigPath()
153− applyDefaultValues()
191+ cfg := defaultConfigBasedOnEnv()
192+ cfg.Options.Debug = debug
154 defaultLevel := slog.LevelInfo
193 defaultLevel := slog.LevelInfo
155− if cfg.Debug {
194+ if cfg.Options.Debug {
156 defaultLevel = slog.LevelDebug
195 defaultLevel = slog.LevelDebug
158 if os.Getenv("CRUSH_DEV_DEBUG") == "true" {
197 if os.Getenv("CRUSH_DEV_DEBUG") == "true" {
159− loggingFile := fmt.Sprintf("%s/%s", cfg.Data.Directory, "debug.log")
198+ loggingFile := fmt.Sprintf("%s/%s", cfg.Options.DataDirectory, "debug.log")
161 // if file does not exist create it
200 // if file does not exist create it
162 if _, err := os.Stat(loggingFile); os.IsNotExist(err) {
201 if _, err := os.Stat(loggingFile); os.IsNotExist(err) {
163− if err := os.MkdirAll(cfg.Data.Directory, 0o755); err != nil {
202+ if err := os.MkdirAll(cfg.Options.DataDirectory, 0o755); err != nil {
164 return cfg, fmt.Errorf("failed to create directory: %w", err)
203 return cfg, fmt.Errorf("failed to create directory: %w", err)
166 if _, err := os.Create(loggingFile); err != nil {
205 if _, err := os.Create(loggingFile); err != nil {
@@ -184,734 +223,530 @@ func Load(workingDir string, debug bool) (*Config, error) {
185 slog.SetDefault(logger)
224 slog.SetDefault(logger)
187−
226+ var globalCfg *Config
188− // Validate configuration
227+ if _, err := os.Stat(cfgPath); err != nil && !os.IsNotExist(err) {
189− if err := Validate(); err != nil {
228+ // some other error occurred while checking the file
190− return cfg, fmt.Errorf("config validation failed: %w", err)
229+ return nil, err
191− }
230+ } else if err == nil {
192−
231+ // config file exists, read it
193− if cfg.Agents == nil {
232+ file, err := os.ReadFile(cfgPath)
194− cfg.Agents = make(map[AgentName]Agent)
233+ if err != nil {
195− }
234+ return nil, err
197− // Override the max tokens for title agent
198− cfg.Agents[AgentTitle] = Agent{
199− Model: cfg.Agents[AgentTitle].Model,
205−type configFinder struct {
211−func (f configFinder) Find(fsys afero.Fs) ([]string, error) {
212− var configFiles []string
213− configName := fmt.Sprintf("%s.json", f.appName)
215− configName = fmt.Sprintf(".%s.json", f.appName)
218− for _, p := range f.paths {
222− paths = append(paths, os.ExpandEnv(p))
236+ globalCfg = &Config{}
223− }
237+ if err := json.Unmarshal(file, globalCfg); err != nil {
225− for _, path := range paths {
230− configPath := filepath.Join(path, configName)
241+ // config file does not exist, create a new one
231− if exists, err := afero.Exists(fsys, configPath); err == nil && exists {
242+ globalCfg = &Config{}
232− configFiles = append(configFiles, configPath)
243+ }
245+ var localConfig *Config
246+ // Global config loaded, now read the local config file
247+ localConfigPath := filepath.Join(cwd, "crush.json")
248+ if _, err := os.Stat(localConfigPath); err != nil && !os.IsNotExist(err) {
249+ // some other error occurred while checking the file
251+ } else if err == nil {
252+ // local config file exists, read it
253+ file, err := os.ReadFile(localConfigPath)
257+ localConfig = &Config{}
258+ if err := json.Unmarshal(file, localConfig); err != nil {
235− return configFiles, nil
238−// configureViper sets up viper's configuration paths and environment variables.
263+ // merge options
239−func configureViper() {
264+ mergeOptions(cfg, globalCfg, localConfig)
240− viper.SetConfigType("json")
242− // Create the three finders
243− windowsFinder := configFinder{appName: appName, dotPrefix: false, paths: []string{
245− fmt.Sprintf("$APPDATA/%s", appName),
246− fmt.Sprintf("$LOCALAPPDATA/%s", appName),
249− unixFinder := configFinder{appName: appName, dotPrefix: false, paths: []string{
251− fmt.Sprintf("$XDG_CONFIG_HOME/%s", appName),
252− fmt.Sprintf("$HOME/.config/%s", appName),
255− localFinder := configFinder{appName: appName, dotPrefix: true, paths: []string{
259− // Use all finders with viper
260− viper.SetOptions(viper.WithFinder(viper.Finders(windowsFinder, unixFinder, localFinder)))
261− viper.SetEnvPrefix(strings.ToUpper(appName))
262− viper.AutomaticEnv()
265−// setDefaults configures default values for configuration options.
266+ mergeProviderConfigs(cfg, globalCfg, localConfig)
266−func setDefaults(debug bool) {
267+ // no providers found the app is not initialized yet
267− viper.SetDefault("data.directory", defaultDataDirectory)
268+ if len(cfg.Providers) == 0 {
268− viper.SetDefault("contextPaths", defaultContextPaths)
269+ return cfg, nil
269− viper.SetDefault("tui.theme", "crush")
270− viper.SetDefault("autoCompact", true)
273− viper.SetDefault("debug", true)
274− viper.Set("log.level", "debug")
276− viper.SetDefault("debug", false)
277− viper.SetDefault("log.level", defaultLogLevel)
271+ preferredProvider := getPreferredProvider(cfg.Providers)
272+ cfg.Models = PreferredModels{
273+ Large: PreferredModel{
274+ ModelID: preferredProvider.DefaultLargeModel,
275+ Provider: preferredProvider.ID,
277+ Small: PreferredModel{
278+ ModelID: preferredProvider.DefaultSmallModel,
279+ Provider: preferredProvider.ID,
283+ mergeModels(cfg, globalCfg, localConfig)
285+ if preferredProvider == nil {
286+ return nil, errors.New("no valid providers configured")
289+ agents := map[AgentID]Agent{
293+ Description: "An agent that helps with executing coding tasks.",
294+ Provider: cfg.Models.Large.Provider,
295+ Model: cfg.Models.Large.ModelID,
296+ ContextPaths: cfg.Options.ContextPaths,
297+ // All tools allowed
302+ Description: "An agent that helps with searching for context and finding implementation details.",
303+ Provider: cfg.Models.Large.Provider,
304+ Model: cfg.Models.Large.ModelID,
305+ ContextPaths: cfg.Options.ContextPaths,
306+ AllowedTools: []string{
313+ // NO MCPs or LSPs by default
314+ AllowedMCP: map[string][]string{},
315+ AllowedLSP: []string{},
319+ mergeAgents(cfg, globalCfg, localConfig)
320+ mergeMCPs(cfg, globalCfg, localConfig)
321+ mergeLSPs(cfg, globalCfg, localConfig)
281−// setProviderDefaults configures LLM provider defaults based on provider provided by
282−// environment variables and configuration file.
283−func setProviderDefaults() {
284− // Set all API keys we can find in the environment
285− if apiKey := os.Getenv("ANTHROPIC_API_KEY"); apiKey != "" {
286− viper.SetDefault("providers.anthropic.apiKey", apiKey)
288− if apiKey := os.Getenv("OPENAI_API_KEY"); apiKey != "" {
289− viper.SetDefault("providers.openai.apiKey", apiKey)
291− if apiKey := os.Getenv("GEMINI_API_KEY"); apiKey != "" {
292− viper.SetDefault("providers.gemini.apiKey", apiKey)
294− if apiKey := os.Getenv("GROQ_API_KEY"); apiKey != "" {
295− viper.SetDefault("providers.groq.apiKey", apiKey)
297− if apiKey := os.Getenv("OPENROUTER_API_KEY"); apiKey != "" {
298− viper.SetDefault("providers.openrouter.apiKey", apiKey)
300− if apiKey := os.Getenv("XAI_API_KEY"); apiKey != "" {
301− viper.SetDefault("providers.xai.apiKey", apiKey)
303− if apiKey := os.Getenv("AZURE_OPENAI_ENDPOINT"); apiKey != "" {
304− // api-key may be empty when using Entra ID credentials – that's okay
305− viper.SetDefault("providers.azure.apiKey", os.Getenv("AZURE_OPENAI_API_KEY"))
308− // Use this order to set the default models
316− // 8. Google Cloud VertexAI
318− // Anthropic configuration
319− if key := viper.GetString("providers.anthropic.apiKey"); strings.TrimSpace(key) != "" {
320− viper.SetDefault("agents.coder.model", models.Claude4Sonnet)
321− viper.SetDefault("agents.summarizer.model", models.Claude4Sonnet)
322− viper.SetDefault("agents.task.model", models.Claude4Sonnet)
323− viper.SetDefault("agents.title.model", models.Claude4Sonnet)
324− return
323+ return cfg, nil
327− // OpenAI configuration
328− if key := viper.GetString("providers.openai.apiKey"); strings.TrimSpace(key) != "" {
329− viper.SetDefault("agents.coder.model", models.GPT41)
330− viper.SetDefault("agents.summarizer.model", models.GPT41)
331− viper.SetDefault("agents.task.model", models.GPT41Mini)
332− viper.SetDefault("agents.title.model", models.GPT41Mini)
336− // Google Gemini configuration
337− if key := viper.GetString("providers.gemini.apiKey"); strings.TrimSpace(key) != "" {
338− viper.SetDefault("agents.coder.model", models.Gemini25)
339− viper.SetDefault("agents.summarizer.model", models.Gemini25)
340− viper.SetDefault("agents.task.model", models.Gemini25Flash)
341− viper.SetDefault("agents.title.model", models.Gemini25Flash)
345− // Groq configuration
346− if key := viper.GetString("providers.groq.apiKey"); strings.TrimSpace(key) != "" {
347− viper.SetDefault("agents.coder.model", models.QWENQwq)
348− viper.SetDefault("agents.summarizer.model", models.QWENQwq)
349− viper.SetDefault("agents.task.model", models.QWENQwq)
350− viper.SetDefault("agents.title.model", models.QWENQwq)
354− // OpenRouter configuration
355− if key := viper.GetString("providers.openrouter.apiKey"); strings.TrimSpace(key) != "" {
356− viper.SetDefault("agents.coder.model", models.OpenRouterClaude37Sonnet)
357− viper.SetDefault("agents.summarizer.model", models.OpenRouterClaude37Sonnet)
358− viper.SetDefault("agents.task.model", models.OpenRouterClaude37Sonnet)
359− viper.SetDefault("agents.title.model", models.OpenRouterClaude35Haiku)
363− // XAI configuration
364− if key := viper.GetString("providers.xai.apiKey"); strings.TrimSpace(key) != "" {
365− viper.SetDefault("agents.coder.model", models.XAIGrok3Beta)
366− viper.SetDefault("agents.summarizer.model", models.XAIGrok3Beta)
367− viper.SetDefault("agents.task.model", models.XAIGrok3Beta)
368− viper.SetDefault("agents.title.model", models.XAiGrok3MiniFastBeta)
372− // AWS Bedrock configuration
373− if hasAWSCredentials() {
374− viper.SetDefault("agents.coder.model", models.BedrockClaude37Sonnet)
375− viper.SetDefault("agents.summarizer.model", models.BedrockClaude37Sonnet)
376− viper.SetDefault("agents.task.model", models.BedrockClaude37Sonnet)
377− viper.SetDefault("agents.title.model", models.BedrockClaude37Sonnet)
381− // Azure OpenAI configuration
382− if os.Getenv("AZURE_OPENAI_ENDPOINT") != "" {
383− viper.SetDefault("agents.coder.model", models.AzureGPT41)
384− viper.SetDefault("agents.summarizer.model", models.AzureGPT41)
385− viper.SetDefault("agents.task.model", models.AzureGPT41Mini)
386− viper.SetDefault("agents.title.model", models.AzureGPT41Mini)
390− // Google Cloud VertexAI configuration
391− if hasVertexAICredentials() {
392− viper.SetDefault("agents.coder.model", models.VertexAIGemini25)
393− viper.SetDefault("agents.summarizer.model", models.VertexAIGemini25)
394− viper.SetDefault("agents.task.model", models.VertexAIGemini25Flash)
395− viper.SetDefault("agents.title.model", models.VertexAIGemini25Flash)
400−// hasAWSCredentials checks if AWS credentials are available in the environment.
326+func Init(workingDir string, debug bool) (*Config, error) {
401−func hasAWSCredentials() bool {
327+ var err error
402− // Check for explicit AWS credentials
328+ once.Do(func() {
403− if os.Getenv("AWS_ACCESS_KEY_ID") != "" && os.Getenv("AWS_SECRET_ACCESS_KEY") != "" {
329+ cwd = workingDir
404− return true
330+ instance, err = loadConfig(cwd, debug)
405− }
331+ if err != nil {
406−
332+ logging.Error("Failed to load config", "error", err)
407− // Check for AWS profile
333+ }
408− if os.Getenv("AWS_PROFILE") != "" || os.Getenv("AWS_DEFAULT_PROFILE") != "" {
412− // Check for AWS region
413− if os.Getenv("AWS_REGION") != "" || os.Getenv("AWS_DEFAULT_REGION") != "" {
414− return true
336+ return instance, err
417− // Check if running on EC2 with instance profile
339+func Get() *Config {
418− if os.Getenv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI") != "" ||
340+ if instance == nil {
419− os.Getenv("AWS_CONTAINER_CREDENTIALS_FULL_URI") != "" {
341+ // TODO: Handle this better
420− return true
342+ panic("Config not initialized. Call InitConfig first.")
423− return false
344+ return instance
426−// hasVertexAICredentials checks if VertexAI credentials are available in the environment.
347+func getPreferredProvider(configuredProviders map[provider.InferenceProvider]ProviderConfig) *ProviderConfig {
427−func hasVertexAICredentials() bool {
348+ providers := Providers()
428− // Check for explicit VertexAI parameters
349+ for _, p := range providers {
429− if os.Getenv("VERTEXAI_PROJECT") != "" && os.Getenv("VERTEXAI_LOCATION") != "" {
350+ if providerConfig, ok := configuredProviders[p.ID]; ok && !providerConfig.Disabled {
430− return true
351+ return &providerConfig
432− // Check for Google Cloud project and location
354+ // if none found return the first configured provider
433− if os.Getenv("GOOGLE_CLOUD_PROJECT") != "" && (os.Getenv("GOOGLE_CLOUD_REGION") != "" || os.Getenv("GOOGLE_CLOUD_LOCATION") != "") {
355+ for _, providerConfig := range configuredProviders {
356+ if !providerConfig.Disabled {
434− return true
357+ return &providerConfig
436− return false
360+ return nil
439−// readConfig handles the result of reading a configuration file.
363+func mergeProviderConfig(p provider.InferenceProvider, base, other ProviderConfig) ProviderConfig {
440−func readConfig(err error) error {
364+ if other.APIKey != "" {
441− if err == nil {
365+ base.APIKey = other.APIKey
444−
367+ // Only change these options if the provider is not a known provider
445− // It's okay if the config file doesn't exist
368+ if !slices.Contains(provider.KnownProviders(), p) {
446− if _, ok := err.(viper.ConfigFileNotFoundError); ok {
369+ if other.BaseURL != "" {
447− return nil
370+ base.BaseURL = other.BaseURL
372+ if other.ProviderType != "" {
373+ base.ProviderType = other.ProviderType
375+ if len(base.ExtraHeaders) > 0 {
376+ if base.ExtraHeaders == nil {
377+ base.ExtraHeaders = make(map[string]string)
379+ maps.Copy(base.ExtraHeaders, other.ExtraHeaders)
381+ if len(other.ExtraParams) > 0 {
382+ if base.ExtraParams == nil {
383+ base.ExtraParams = make(map[string]string)
385+ maps.Copy(base.ExtraParams, other.ExtraParams)
450− return fmt.Errorf("failed to read config: %w", err)
389+ if other.Disabled {
451−}
390+ base.Disabled = other.Disabled
453−// mergeLocalConfig loads and merges configuration from the local directory.
454−func mergeLocalConfig(workingDir string) {
455− local := viper.New()
456− local.SetConfigName(fmt.Sprintf(".%s", appName))
457− local.SetConfigType("json")
458− local.AddConfigPath(workingDir)
460− // Merge local config if it exists
461− if err := local.ReadInConfig(); err == nil {
462− viper.MergeConfigMap(local.AllSettings())
466−// applyDefaultValues sets default values for configuration fields that need processing.
393+ if other.DefaultLargeModel != "" {
467−func applyDefaultValues() {
394+ base.DefaultLargeModel = other.DefaultLargeModel
468− // Set default MCP type if not specified
469− for k, v := range cfg.MCPServers {
472− cfg.MCPServers[k] = v
475−}
396+ // Add new models if they don't exist
476−
397+ if other.Models != nil {
477−// It validates model IDs and providers, ensuring they are supported.
398+ for _, model := range other.Models {
478−func validateAgent(cfg *Config, name AgentName, agent Agent) error {
399+ // check if the model already exists
479− // Check if model exists
400+ exists := false
480− model, modelExists := models.SupportedModels[agent.Model]
401+ for _, existingModel := range base.Models {
481− if !modelExists {
402+ if existingModel.ID == model.ID {
482− logging.Warn("unsupported model configured, reverting to default",
403+ exists = true
483− "agent", name,
404+ break
484− "configured_model", agent.Model)
405+ }
486− // Set default model based on available providers
487− if setDefaultModelForAgent(name) {
488− logging.Info("set default model for agent", "agent", name, "model", cfg.Agents[name].Model)
490− return fmt.Errorf("no valid provider available for agent %s", name)
495− // Check if provider for the model is configured
496− provider := model.Provider
497− providerCfg, providerExists := cfg.Providers[provider]
499− if !providerExists {
500− // Provider not configured, check if we have environment variables
501− apiKey := getProviderAPIKey(provider)
503− logging.Warn("provider not configured for model, reverting to default",
505− "model", agent.Model,
506− "provider", provider)
508− // Set default model based on available providers
509− if setDefaultModelForAgent(name) {
510− logging.Info("set default model for agent", "agent", name, "model", cfg.Agents[name].Model)
512− return fmt.Errorf("no valid provider available for agent %s", name)
514− } else {
407+ if !exists {
515− // Add provider with API key from environment
408+ base.Models = append(base.Models, model)
516− cfg.Providers[provider] = Provider{
519− logging.Info("added provider from environment", "provider", provider)
521− } else if providerCfg.Disabled || providerCfg.APIKey == "" {
522− // Provider is disabled or has no API key
523− logging.Warn("provider is disabled or has no API key, reverting to default",
525− "model", agent.Model,
526− "provider", provider)
528− // Set default model based on available providers
529− if setDefaultModelForAgent(name) {
530− logging.Info("set default model for agent", "agent", name, "model", cfg.Agents[name].Model)
532− return fmt.Errorf("no valid provider available for agent %s", name)
536− // Validate max tokens
537− if agent.MaxTokens <= 0 {
538− logging.Warn("invalid max tokens, setting to default",
540− "model", agent.Model,
541− "max_tokens", agent.MaxTokens)
543− // Update the agent with default max tokens
544− updatedAgent := cfg.Agents[name]
545− if model.DefaultMaxTokens > 0 {
546− updatedAgent.MaxTokens = model.DefaultMaxTokens
548− updatedAgent.MaxTokens = MaxTokensFallbackDefault
550− cfg.Agents[name] = updatedAgent
551− } else if model.ContextWindow > 0 && agent.MaxTokens > model.ContextWindow/2 {
552− // Ensure max tokens doesn't exceed half the context window (reasonable limit)
553− logging.Warn("max tokens exceeds half the context window, adjusting",
555− "model", agent.Model,
556− "max_tokens", agent.MaxTokens,
557− "context_window", model.ContextWindow)
559− // Update the agent with adjusted max tokens
560− updatedAgent := cfg.Agents[name]
561− updatedAgent.MaxTokens = model.ContextWindow / 2
562− cfg.Agents[name] = updatedAgent
565− // Validate reasoning effort for models that support reasoning
566− if model.CanReason && provider == models.ProviderOpenAI || provider == models.ProviderLocal {
567− if agent.ReasoningEffort == "" {
568− // Set default reasoning effort for models that support it
569− logging.Info("setting default reasoning effort for model that supports reasoning",
571− "model", agent.Model)
573− // Update the agent with default reasoning effort
574− updatedAgent := cfg.Agents[name]
575− updatedAgent.ReasoningEffort = "medium"
576− cfg.Agents[name] = updatedAgent
578− // Check if reasoning effort is valid (low, medium, high)
579− effort := strings.ToLower(agent.ReasoningEffort)
580− if effort != "low" && effort != "medium" && effort != "high" {
581− logging.Warn("invalid reasoning effort, setting to medium",
583− "model", agent.Model,
584− "reasoning_effort", agent.ReasoningEffort)
586− // Update the agent with valid reasoning effort
587− updatedAgent := cfg.Agents[name]
588− updatedAgent.ReasoningEffort = "medium"
589− cfg.Agents[name] = updatedAgent
592− } else if !model.CanReason && agent.ReasoningEffort != "" {
593− // Model doesn't support reasoning but reasoning effort is set
594− logging.Warn("model doesn't support reasoning but reasoning effort is set, ignoring",
596− "model", agent.Model,
597− "reasoning_effort", agent.ReasoningEffort)
599− // Update the agent to remove reasoning effort
600− updatedAgent := cfg.Agents[name]
601− updatedAgent.ReasoningEffort = ""
602− cfg.Agents[name] = updatedAgent
605− return nil
413+ return base
608−// Validate checks if the configuration is valid and applies defaults where needed.
416+func validateProvider(p provider.InferenceProvider, providerConfig ProviderConfig) error {
609−func Validate() error {
417+ if !slices.Contains(provider.KnownProviders(), p) {
610− if cfg == nil {
418+ if providerConfig.ProviderType != provider.TypeOpenAI {
611− return fmt.Errorf("config not loaded")
419+ return errors.New("invalid provider type: " + string(providerConfig.ProviderType))
614− // Validate agent models
615− for name, agent := range cfg.Agents {
616− if err := validateAgent(cfg, name, agent); err != nil {
619− }
421+ if providerConfig.BaseURL == "" {
620−
422+ return errors.New("base URL must be set for custom providers")
621− // Validate providers
622− for provider, providerCfg := range cfg.Providers {
623− if providerCfg.APIKey == "" && !providerCfg.Disabled {
624− logging.Warn("provider has no API key, marking as disabled", "provider", provider)
625− providerCfg.Disabled = true
626− cfg.Providers[provider] = providerCfg
628− }
424+ if providerConfig.APIKey == "" {
629−
425+ return errors.New("API key must be set for custom providers")
630− // Validate LSP configurations
631− for language, lspConfig := range cfg.LSP {
632− if lspConfig.Command == "" && !lspConfig.Disabled {
633− logging.Warn("LSP configuration has no command, marking as disabled", "language", language)
634− lspConfig.Disabled = true
635− cfg.LSP[language] = lspConfig
639 return nil
428 return nil
642−// getProviderAPIKey gets the API key for a provider from environment variables
431+func mergeModels(base, global, local *Config) {
643−func getProviderAPIKey(provider models.InferenceProvider) string {
432+ for _, cfg := range []*Config{global, local} {
644− switch provider {
433+ if cfg == nil {
645− case models.ProviderAnthropic:
434+ continue
646− return os.Getenv("ANTHROPIC_API_KEY")
647− case models.ProviderOpenAI:
648− return os.Getenv("OPENAI_API_KEY")
649− case models.ProviderGemini:
650− return os.Getenv("GEMINI_API_KEY")
651− case models.ProviderGROQ:
652− return os.Getenv("GROQ_API_KEY")
653− case models.ProviderAzure:
654− return os.Getenv("AZURE_OPENAI_API_KEY")
655− case models.ProviderOpenRouter:
656− return os.Getenv("OPENROUTER_API_KEY")
657− case models.ProviderBedrock:
658− if hasAWSCredentials() {
659− return "aws-credentials-available"
661− case models.ProviderVertexAI:
662− if hasVertexAICredentials() {
663− return "vertex-ai-credentials-available"
669−// setDefaultModelForAgent sets a default model for an agent based on available providers
670−func setDefaultModelForAgent(agent AgentName) bool {
671− // Check providers in order of preference
672− if apiKey := os.Getenv("ANTHROPIC_API_KEY"); apiKey != "" {
673− maxTokens := int64(5000)
674− if agent == AgentTitle {
677− cfg.Agents[agent] = Agent{
436+ if cfg.Models.Large.ModelID != "" && cfg.Models.Large.Provider != "" {
678− Model: models.Claude37Sonnet,
437+ base.Models.Large = cfg.Models.Large
679− MaxTokens: maxTokens,
684− if apiKey := os.Getenv("OPENAI_API_KEY"); apiKey != "" {
440+ if cfg.Models.Small.ModelID != "" && cfg.Models.Small.Provider != "" {
685− var model models.ModelID
441+ base.Models.Small = cfg.Models.Small
686− maxTokens := int64(5000)
687− reasoningEffort := ""
691− model = models.GPT41Mini
694− model = models.GPT41Mini
696− model = models.GPT41
699− // Check if model supports reasoning
446+func mergeOptions(base, global, local *Config) {
700− if modelInfo, ok := models.SupportedModels[model]; ok && modelInfo.CanReason {
447+ for _, cfg := range []*Config{global, local} {
701− reasoningEffort = "medium"
448+ if cfg == nil {
451+ baseOptions := base.Options
452+ other := cfg.Options
453+ if len(other.ContextPaths) > 0 {
454+ baseOptions.ContextPaths = append(baseOptions.ContextPaths, other.ContextPaths...)
704− cfg.Agents[agent] = Agent{
457+ if other.TUI.CompactMode {
705− Model: model,
458+ baseOptions.TUI.CompactMode = other.TUI.CompactMode
706− MaxTokens: maxTokens,
707− ReasoningEffort: reasoningEffort,
712− if apiKey := os.Getenv("OPENROUTER_API_KEY"); apiKey != "" {
461+ if other.Debug {
713− var model models.ModelID
462+ baseOptions.Debug = other.Debug
714− maxTokens := int64(5000)
463+ }
715− reasoningEffort := ""
717− switch agent {
465+ if other.DebugLSP {
718− case AgentTitle:
466+ baseOptions.DebugLSP = other.DebugLSP
719− model = models.OpenRouterClaude35Haiku
722− model = models.OpenRouterClaude37Sonnet
724− model = models.OpenRouterClaude37Sonnet
727− // Check if model supports reasoning
469+ if other.DisableAutoSummarize {
728− if modelInfo, ok := models.SupportedModels[model]; ok && modelInfo.CanReason {
470+ baseOptions.DisableAutoSummarize = other.DisableAutoSummarize
729− reasoningEffort = "medium"
732− cfg.Agents[agent] = Agent{
473+ if other.DataDirectory != "" {
733− Model: model,
474+ baseOptions.DataDirectory = other.DataDirectory
734− MaxTokens: maxTokens,
735− ReasoningEffort: reasoningEffort,
737− return true
476+ base.Options = baseOptions
740− if apiKey := os.Getenv("GEMINI_API_KEY"); apiKey != "" {
480+func mergeAgents(base, global, local *Config) {
741− var model models.ModelID
481+ for _, cfg := range []*Config{global, local} {
742− maxTokens := int64(5000)
482+ if cfg == nil {
744− if agent == AgentTitle {
745− model = models.Gemini25Flash
748− model = models.Gemini25
750−
485+ for agentID, newAgent := range cfg.Agents {
751− cfg.Agents[agent] = Agent{
486+ if _, ok := base.Agents[agentID]; !ok {
752− Model: model,
487+ newAgent.ID = agentID // Ensure the ID is set correctly
753− MaxTokens: maxTokens,
488+ base.Agents[agentID] = newAgent
492+ baseAgent := base.Agents[agentID]
493+ if newAgent.Model != "" && newAgent.Provider != "" {
494+ baseAgent.Model = newAgent.Model
495+ baseAgent.Provider = newAgent.Provider
497+ baseAgent.AllowedMCP = newAgent.AllowedMCP
498+ baseAgent.AllowedLSP = newAgent.AllowedLSP
499+ base.Agents[agentID] = baseAgent
501+ baseAgent := base.Agents[agentID]
502+ baseAgent.Name = newAgent.Name
503+ baseAgent.Description = newAgent.Description
504+ baseAgent.Disabled = newAgent.Disabled
505+ if newAgent.Model == "" || newAgent.Provider == "" {
506+ baseAgent.Provider = base.Models.Large.Provider
507+ baseAgent.Model = base.Models.Large.ModelID
509+ baseAgent.AllowedTools = newAgent.AllowedTools
510+ baseAgent.AllowedMCP = newAgent.AllowedMCP
511+ baseAgent.AllowedLSP = newAgent.AllowedLSP
512+ base.Agents[agentID] = baseAgent
758− if apiKey := os.Getenv("GROQ_API_KEY"); apiKey != "" {
519+func mergeMCPs(base, global, local *Config) {
759− maxTokens := int64(5000)
520+ for _, cfg := range []*Config{global, local} {
760− if agent == AgentTitle {
521+ if cfg == nil {
761− maxTokens = 80
522+ continue
524+ maps.Copy(base.MCP, cfg.MCP)
764− cfg.Agents[agent] = Agent{
528+func mergeLSPs(base, global, local *Config) {
765− Model: models.QWENQwq,
529+ for _, cfg := range []*Config{global, local} {
766− MaxTokens: maxTokens,
530+ if cfg == nil {
768− return true
533+ maps.Copy(base.LSP, cfg.LSP)
771− if hasAWSCredentials() {
537+func mergeProviderConfigs(base, global, local *Config) {
772− maxTokens := int64(5000)
538+ for _, cfg := range []*Config{global, local} {
773− if agent == AgentTitle {
539+ if cfg == nil {
774− maxTokens = 80
540+ continue
776−
542+ for providerName, globalProvider := range cfg.Providers {
777− cfg.Agents[agent] = Agent{
543+ if _, ok := base.Providers[providerName]; !ok {
778− Model: models.BedrockClaude37Sonnet,
544+ base.Providers[providerName] = globalProvider
779− MaxTokens: maxTokens,
545+ } else {
780− ReasoningEffort: "medium", // Claude models support reasoning
546+ base.Providers[providerName] = mergeProviderConfig(providerName, base.Providers[providerName], globalProvider)
785− if hasVertexAICredentials() {
551+ finalProviders := make(map[provider.InferenceProvider]ProviderConfig)
786− var model models.ModelID
552+ for providerName, providerConfig := range base.Providers {
787− maxTokens := int64(5000)
553+ err := validateProvider(providerName, providerConfig)
789− if agent == AgentTitle {
555+ logging.Warn("Skipping provider", "name", providerName, "error", err)
790− model = models.VertexAIGemini25Flash
793− model = models.VertexAIGemini25
557+ finalProviders[providerName] = providerConfig
559+ base.Providers = finalProviders
796− cfg.Agents[agent] = Agent{
562+func providerDefaultConfig(providerId provider.InferenceProvider) ProviderConfig {
797− Model: model,
563+ switch providerId {
798− MaxTokens: maxTokens,
564+ case provider.InferenceProviderAnthropic:
565+ return ProviderConfig{
567+ ProviderType: provider.TypeAnthropic,
569+ case provider.InferenceProviderOpenAI:
570+ return ProviderConfig{
572+ ProviderType: provider.TypeOpenAI,
574+ case provider.InferenceProviderGemini:
575+ return ProviderConfig{
577+ ProviderType: provider.TypeGemini,
579+ case provider.InferenceProviderBedrock:
580+ return ProviderConfig{
582+ ProviderType: provider.TypeBedrock,
584+ case provider.InferenceProviderAzure:
585+ return ProviderConfig{
587+ ProviderType: provider.TypeAzure,
589+ case provider.InferenceProviderOpenRouter:
590+ return ProviderConfig{
592+ ProviderType: provider.TypeOpenAI,
593+ BaseURL: "https://openrouter.ai/api/v1",
594+ ExtraHeaders: map[string]string{
595+ "HTTP-Referer": "crush.charm.land",
599+ case provider.InferenceProviderXAI:
600+ return ProviderConfig{
602+ ProviderType: provider.TypeXAI,
603+ BaseURL: "https://api.x.ai/v1",
605+ case provider.InferenceProviderVertexAI:
606+ return ProviderConfig{
608+ ProviderType: provider.TypeVertexAI,
611+ return ProviderConfig{
613+ ProviderType: provider.TypeOpenAI,
806−func updateCfgFile(updateCfg func(config *Config)) error {
618+func defaultConfigBasedOnEnv() *Config {
807− if cfg == nil {
619+ cfg := &Config{
808− return fmt.Errorf("config not loaded")
620+ Options: Options{
621+ DataDirectory: defaultDataDirectory,
622+ ContextPaths: defaultContextPaths,
624+ Providers: make(map[provider.InferenceProvider]ProviderConfig),
627+ providers := Providers()
629+ for _, p := range providers {
630+ if strings.HasPrefix(p.APIKey, "$") {
631+ envVar := strings.TrimPrefix(p.APIKey, "$")
632+ if apiKey := os.Getenv(envVar); apiKey != "" {
633+ providerConfig := providerDefaultConfig(p.ID)
634+ providerConfig.APIKey = apiKey
635+ providerConfig.DefaultLargeModel = p.DefaultLargeModelID
636+ providerConfig.DefaultSmallModel = p.DefaultSmallModelID
637+ baseURL := p.APIEndpoint
638+ if strings.HasPrefix(baseURL, "$") {
639+ envVar := strings.TrimPrefix(baseURL, "$")
640+ if url := os.Getenv(envVar); url != "" {
644+ providerConfig.BaseURL = baseURL
645+ for _, model := range p.Models {
646+ providerConfig.Models = append(providerConfig.Models, Model{
649+ CostPer1MIn: model.CostPer1MIn,
650+ CostPer1MOut: model.CostPer1MOut,
651+ CostPer1MInCached: model.CostPer1MInCached,
652+ CostPer1MOutCached: model.CostPer1MOutCached,
653+ ContextWindow: model.ContextWindow,
654+ DefaultMaxTokens: model.DefaultMaxTokens,
655+ CanReason: model.CanReason,
656+ SupportsImages: model.SupportsImages,
659+ cfg.Providers[p.ID] = providerConfig
663+ // TODO: support local models
811− // Get the config file path
665+ if useVertexAI := os.Getenv("GOOGLE_GENAI_USE_VERTEXAI"); useVertexAI == "true" {
812− configFile := viper.ConfigFileUsed()
666+ providerConfig := providerDefaultConfig(provider.InferenceProviderVertexAI)
813− var configData []byte
667+ providerConfig.ExtraParams = map[string]string{
814− if configFile == "" {
668+ "project": os.Getenv("GOOGLE_CLOUD_PROJECT"),
815− homeDir, err := os.UserHomeDir()
669+ "location": os.Getenv("GOOGLE_CLOUD_LOCATION"),
817− return fmt.Errorf("failed to get home directory: %w", err)
819− configFile = filepath.Join(homeDir, fmt.Sprintf(".%s.json", appName))
671+ cfg.Providers[provider.InferenceProviderVertexAI] = providerConfig
820− logging.Info("config file not found, creating new one", "path", configFile)
821− configData = []byte(`{}`)
823− // Read the existing config file
824− data, err := os.ReadFile(configFile)
826− return fmt.Errorf("failed to read config file: %w", err)
831− // Parse the JSON
674+ if hasAWSCredentials() {
832− var userCfg *Config
675+ providerConfig := providerDefaultConfig(provider.InferenceProviderBedrock)
833− if err := json.Unmarshal(configData, &userCfg); err != nil {
676+ providerConfig.ExtraParams = map[string]string{
834− return fmt.Errorf("failed to parse config file: %w", err)
677+ "region": os.Getenv("AWS_DEFAULT_REGION"),
679+ if providerConfig.ExtraParams["region"] == "" {
680+ providerConfig.ExtraParams["region"] = os.Getenv("AWS_REGION")
682+ cfg.Providers[provider.InferenceProviderBedrock] = providerConfig
837− updateCfg(userCfg)
687+func hasAWSCredentials() bool {
688+ if os.Getenv("AWS_ACCESS_KEY_ID") != "" && os.Getenv("AWS_SECRET_ACCESS_KEY") != "" {
839− // Write the updated config back to file
692+ if os.Getenv("AWS_PROFILE") != "" || os.Getenv("AWS_DEFAULT_PROFILE") != "" {
840− updatedData, err := json.MarshalIndent(userCfg, "", " ")
693+ return true
842− return fmt.Errorf("failed to marshal config: %w", err)
845− if err := os.WriteFile(configFile, updatedData, 0o644); err != nil {
696+ if os.Getenv("AWS_REGION") != "" || os.Getenv("AWS_DEFAULT_REGION") != "" {
846− return fmt.Errorf("failed to write config file: %w", err)
697+ return true
700+ if os.Getenv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI") != "" ||
701+ os.Getenv("AWS_CONTAINER_CREDENTIALS_FULL_URI") != "" {
849− return nil
702+ return true
852−// Get returns the current configuration.
853−// It's safe to call this function multiple times.
855− return cfg
705+ return false
858−// WorkingDirectory returns the current working directory from the configuration.
859func WorkingDirectory() string {
708func WorkingDirectory() string {
861− panic("config not loaded")
863− return cfg.WorkingDir
709+ return cwd
866−func UpdateAgentModel(agentName AgentName, modelID models.ModelID) error {
712+func GetAgentModel(agentID AgentID) Model {
867− if cfg == nil {
713+ cfg := Get()
868− panic("config not loaded")
714+ agent, ok := cfg.Agents[agentID]
716+ logging.Error("Agent not found", "agent_id", agentID)
871− existingAgentCfg := cfg.Agents[agentName]
720+ providerConfig, ok := cfg.Providers[agent.Provider]
873− model, ok := models.SupportedModels[modelID]
875− return fmt.Errorf("model %s not supported", modelID)
722+ logging.Error("Provider not found for agent", "agent_id", agentID, "provider", agent.Provider)
878− maxTokens := existingAgentCfg.MaxTokens
726+ for _, model := range providerConfig.Models {
879− if model.DefaultMaxTokens > 0 {
727+ if model.ID == agent.Model {
880− maxTokens = model.DefaultMaxTokens
728+ return model
883− newAgentCfg := Agent{
732+ logging.Error("Model not found for agent", "agent_id", agentID, "model", agent.Model)
884− Model: modelID,
733+ return Model{}
885− MaxTokens: maxTokens,
734+}
886− ReasoningEffort: existingAgentCfg.ReasoningEffort,
888− cfg.Agents[agentName] = newAgentCfg
890− if err := validateAgent(cfg, agentName, newAgentCfg); err != nil {
736+func GetProviderModel(provider provider.InferenceProvider, modelID string) Model {
891− // revert config update on failure
737+ cfg := Get()
892− cfg.Agents[agentName] = existingAgentCfg
738+ providerConfig, ok := cfg.Providers[provider]
893− return fmt.Errorf("failed to update agent model: %w", err)
739+ if !ok {
740+ logging.Error("Provider not found", "provider", provider)
896− return updateCfgFile(func(config *Config) {
744+ for _, model := range providerConfig.Models {
897− if config.Agents == nil {
745+ if model.ID == modelID {
898− config.Agents = make(map[AgentName]Agent)
746+ return model
900− config.Agents[agentName] = newAgentCfg
904−// UpdateTheme updates the theme in the configuration and writes it to the config file.
905−func UpdateTheme(themeName string) error {
907− return fmt.Errorf("config not loaded")
910− // Update the in-memory config
750+ logging.Error("Model not found for provider", "provider", provider, "model_id", modelID)
911− cfg.TUI.Theme = themeName
751+ return Model{}
913− // Update the file config
914− return updateCfgFile(func(config *Config) {
915− config.TUI.Theme = themeName