@@ -1,67 +1,132 @@
1−// Package config manages application configuration from various sources.
21package config
32
43import (
54 "encoding/json"
5+ "errors"
66 "fmt"
77 "log/slog"
8+ "maps"
89 "os"
910 "path/filepath"
11+ "slices"
1012 "strings"
13+ "sync"
1114
12− "github.com/charmbracelet/crush/internal/llm/models"
15+ "github.com/charmbracelet/crush/internal/fur/provider"
1316 "github.com/charmbracelet/crush/internal/logging"
14− "github.com/spf13/afero"
15− "github.com/spf13/viper"
1617)
1718
18−// MCPType defines the type of MCP (Model Control Protocol) server.
19−type MCPType string
20−
21−// Supported MCP types
2219const (
23− MCPStdio MCPType = "stdio"
24− MCPSse MCPType = "sse"
20+ defaultDataDirectory = ".crush"
21+ defaultLogLevel = "info"
22+ appName = "crush"
23+
24+ MaxTokensFallbackDefault = 4096
2525)
2626
27−// MCPServer defines the configuration for a Model Control Protocol server.
28−type MCPServer struct {
29− Command string `json:"command"`
30− Env []string `json:"env"`
31− Args []string `json:"args"`
32− Type MCPType `json:"type"`
33− URL string `json:"url"`
34− Headers map[string]string `json:"headers"`
27+var defaultContextPaths = []string{
28+ ".github/copilot-instructions.md",
29+ ".cursorrules",
30+ ".cursor/rules/",
31+ "CLAUDE.md",
32+ "CLAUDE.local.md",
33+ "GEMINI.md",
34+ "gemini.md",
35+ "crush.md",
36+ "crush.local.md",
37+ "Crush.md",
38+ "Crush.local.md",
39+ "CRUSH.md",
40+ "CRUSH.local.md",
3541}
3642
37−type AgentName string
43+type AgentID string
3844
3945const (
40− AgentCoder AgentName = "coder"
41− AgentSummarizer AgentName = "summarizer"
42− AgentTask AgentName = "task"
43− AgentTitle AgentName = "title"
46+ AgentCoder AgentID = "coder"
47+ AgentTask AgentID = "task"
4448)
4549
46−// Agent defines configuration for different LLM models and their token limits.
47−type Agent struct {
48− Model models.ModelID `json:"model"`
49− MaxTokens int64 `json:"maxTokens"`
50− ReasoningEffort string `json:"reasoningEffort"` // For openai models low,medium,heigh
50+type Model struct {
51+ ID string `json:"id"`
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"`
60+ ReasoningEffort string `json:"reasoning_effort"`
61+ SupportsImages bool `json:"supports_attachments"`
5162}
5263
53−// Provider defines configuration for an LLM provider.
54−type Provider struct {
55− APIKey string `json:"apiKey"`
56− Disabled bool `json:"disabled"`
64+type VertexAIOptions struct {
65+ APIKey string `json:"api_key,omitempty"`
66+ Project string `json:"project,omitempty"`
67+ Location string `json:"location,omitempty"`
5768}
5869
59−// Data defines storage configuration.
60−type Data struct {
61− Directory string `json:"directory,omitempty"`
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"`
79+
80+ DefaultLargeModel string `json:"default_large_model,omitempty"`
81+ DefaultSmallModel string `json:"default_small_model,omitempty"`
82+
83+ Models []Model `json:"models,omitempty"`
84+}
85+
86+type Agent struct {
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"`
92+
93+ Provider provider.InferenceProvider `json:"provider"`
94+ Model string `json:"model"`
95+
96+ // The available tools for the agent
97+ // if this is nil, all tools are available
98+ AllowedTools []string `json:"allowed_tools"`
99+
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"`
105+
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"`
109+
110+ // Overrides the context paths for this agent
111+ ContextPaths []string `json:"context_paths"`
112+}
113+
114+type MCPType string
115+
116+const (
117+ MCPStdio MCPType = "stdio"
118+ MCPSse MCPType = "sse"
119+)
120+
121+type MCP struct {
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"`
62128}
63129
64−// LSPConfig defines configuration for Language Server Protocol integration.
65130type LSPConfig struct {
66131 Disabled bool `json:"enabled"`
67132 Command string `json:"command"`
@@ -69,98 +134,72 @@ type LSPConfig struct {
69134 Options any `json:"options"`
70135}
71136
72−// TUIConfig defines the configuration for the Terminal User Interface.
73−type TUIConfig struct {
74− Theme string `json:"theme,omitempty"`
137+type TUIOptions struct {
138+ CompactMode bool `json:"compact_mode"`
139+ // Here we can add themes later or any TUI related options
75140}
76141
77−// Config is the main configuration structure for the application.
78−type Config struct {
79− Data Data `json:"data"`
80− WorkingDir string `json:"wd,omitempty"`
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"`
86− DebugLSP bool `json:"debugLSP,omitempty"`
87− ContextPaths []string `json:"contextPaths,omitempty"`
88− TUI TUIConfig `json:"tui"`
89− AutoCompact bool `json:"autoCompact,omitempty"`
142+type Options struct {
143+ ContextPaths []string `json:"context_paths"`
144+ TUI TUIOptions `json:"tui"`
145+ Debug bool `json:"debug"`
146+ DebugLSP bool `json:"debug_lsp"`
147+ DisableAutoSummarize bool `json:"disable_auto_summarize"`
148+ // Relative to the cwd
149+ DataDirectory string `json:"data_directory"`
90150}
91151
92−// Application constants
93−const (
94− defaultDataDirectory = ".crush"
95− defaultLogLevel = "info"
96− appName = "crush"
97−
98− MaxTokensFallbackDefault = 4096
99−)
152+type PreferredModel struct {
153+ ModelID string `json:"model_id"`
154+ Provider provider.InferenceProvider `json:"provider"`
155+}
100156
101−var defaultContextPaths = []string{
102− ".github/copilot-instructions.md",
103− ".cursorrules",
104− ".cursor/rules/",
105− "CLAUDE.md",
106− "CLAUDE.local.md",
107− "GEMINI.md",
108− "gemini.md",
109− "crush.md",
110− "crush.local.md",
111− "Crush.md",
112− "Crush.local.md",
113− "CRUSH.md",
114− "CRUSH.local.md",
157+type PreferredModels struct {
158+ Large PreferredModel `json:"large"`
159+ Small PreferredModel `json:"small"`
115160}
116161
117−// Global configuration instance
118−var cfg *Config
162+type Config struct {
163+ Models PreferredModels `json:"models"`
164+ // List of configured providers
165+ Providers map[provider.InferenceProvider]ProviderConfig `json:"providers,omitempty"`
119166
120−// Load initializes the configuration from environment variables and config files.
121−// If debug is true, debug mode is enabled and log level is set to debug.
122−// It returns an error if configuration loading fails.
123−func Load(workingDir string, debug bool) (*Config, error) {
124− if cfg != nil {
125− return cfg, nil
126− }
167+ // List of configured agents
168+ Agents map[AgentID]Agent `json:"agents,omitempty"`
127169
128− cfg = &Config{
129− WorkingDir: workingDir,
130− MCPServers: make(map[string]MCPServer),
131− Providers: make(map[models.InferenceProvider]Provider),
132− LSP: make(map[string]LSPConfig),
133− }
170+ // List of configured MCPs
171+ MCP map[string]MCP `json:"mcp,omitempty"`
134172
135− configureViper()
136− setDefaults(debug)
173+ // List of configured LSPs
174+ LSP map[string]LSPConfig `json:"lsp,omitempty"`
137175
138− // Read global config
139− if err := readConfig(viper.ReadInConfig()); err != nil {
140− return cfg, err
141− }
176+ // Miscellaneous options
177+ Options Options `json:"options"`
178+}
142179
143− // Load and merge local config
144− mergeLocalConfig(workingDir)
180+var (
181+ instance *Config // The single instance of the Singleton
182+ cwd string
183+ once sync.Once // Ensures the initialization happens only once
145184
146− setProviderDefaults()
185+)
147186
148− // Apply configuration to the struct
149− if err := viper.Unmarshal(cfg); err != nil {
150− return cfg, fmt.Errorf("failed to unmarshal config: %w", err)
151− }
187+func loadConfig(cwd string, debug bool) (*Config, error) {
188+ // First read the global config file
189+ cfgPath := ConfigPath()
152190
153− applyDefaultValues()
191+ cfg := defaultConfigBasedOnEnv()
192+ cfg.Options.Debug = debug
154193 defaultLevel := slog.LevelInfo
155− if cfg.Debug {
194+ if cfg.Options.Debug {
156195 defaultLevel = slog.LevelDebug
157196 }
158197 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")
160199
161200 // if file does not exist create it
162201 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 {
164203 return cfg, fmt.Errorf("failed to create directory: %w", err)
165204 }
166205 if _, err := os.Create(loggingFile); err != nil {
@@ -184,734 +223,530 @@ func Load(workingDir string, debug bool) (*Config, error) {
184223 }))
185224 slog.SetDefault(logger)
186225 }
187−
188− // Validate configuration
189− if err := Validate(); err != nil {
190− return cfg, fmt.Errorf("config validation failed: %w", err)
191− }
192−
193− if cfg.Agents == nil {
194− cfg.Agents = make(map[AgentName]Agent)
195− }
196−
197− // Override the max tokens for title agent
198− cfg.Agents[AgentTitle] = Agent{
199− Model: cfg.Agents[AgentTitle].Model,
200− MaxTokens: 80,
201− }
202− return cfg, nil
203−}
204−
205−type configFinder struct {
206− appName string
207− dotPrefix bool
208− paths []string
209−}
210−
211−func (f configFinder) Find(fsys afero.Fs) ([]string, error) {
212− var configFiles []string
213− configName := fmt.Sprintf("%s.json", f.appName)
214− if f.dotPrefix {
215− configName = fmt.Sprintf(".%s.json", f.appName)
216− }
217− paths := []string{}
218− for _, p := range f.paths {
219− if p == "" {
220− continue
226+ var globalCfg *Config
227+ if _, err := os.Stat(cfgPath); err != nil && !os.IsNotExist(err) {
228+ // some other error occurred while checking the file
229+ return nil, err
230+ } else if err == nil {
231+ // config file exists, read it
232+ file, err := os.ReadFile(cfgPath)
233+ if err != nil {
234+ return nil, err
221235 }
222− paths = append(paths, os.ExpandEnv(p))
223− }
224−
225− for _, path := range paths {
226− if path == "" {
227− continue
236+ globalCfg = &Config{}
237+ if err := json.Unmarshal(file, globalCfg); err != nil {
238+ return nil, err
228239 }
229−
230− configPath := filepath.Join(path, configName)
231− if exists, err := afero.Exists(fsys, configPath); err == nil && exists {
232− configFiles = append(configFiles, configPath)
240+ } else {
241+ // config file does not exist, create a new one
242+ globalCfg = &Config{}
243+ }
244+
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
250+ return nil, err
251+ } else if err == nil {
252+ // local config file exists, read it
253+ file, err := os.ReadFile(localConfigPath)
254+ if err != nil {
255+ return nil, err
256+ }
257+ localConfig = &Config{}
258+ if err := json.Unmarshal(file, localConfig); err != nil {
259+ return nil, err
233260 }
234261 }
235− return configFiles, nil
236−}
237262
238−// configureViper sets up viper's configuration paths and environment variables.
239−func configureViper() {
240− viper.SetConfigType("json")
241−
242− // Create the three finders
243− windowsFinder := configFinder{appName: appName, dotPrefix: false, paths: []string{
244− "$USERPROFILE",
245− fmt.Sprintf("$APPDATA/%s", appName),
246− fmt.Sprintf("$LOCALAPPDATA/%s", appName),
247− }}
248−
249− unixFinder := configFinder{appName: appName, dotPrefix: false, paths: []string{
250− "$HOME",
251− fmt.Sprintf("$XDG_CONFIG_HOME/%s", appName),
252− fmt.Sprintf("$HOME/.config/%s", appName),
253− }}
254−
255− localFinder := configFinder{appName: appName, dotPrefix: true, paths: []string{
256− ".",
257− }}
258−
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()
263−}
263+ // merge options
264+ mergeOptions(cfg, globalCfg, localConfig)
264265
265−// setDefaults configures default values for configuration options.
266−func setDefaults(debug bool) {
267− viper.SetDefault("data.directory", defaultDataDirectory)
268− viper.SetDefault("contextPaths", defaultContextPaths)
269− viper.SetDefault("tui.theme", "crush")
270− viper.SetDefault("autoCompact", true)
271−
272− if debug {
273− viper.SetDefault("debug", true)
274− viper.Set("log.level", "debug")
275− } else {
276− viper.SetDefault("debug", false)
277− viper.SetDefault("log.level", defaultLogLevel)
266+ mergeProviderConfigs(cfg, globalCfg, localConfig)
267+ // no providers found the app is not initialized yet
268+ if len(cfg.Providers) == 0 {
269+ return cfg, nil
278270 }
279−}
271+ preferredProvider := getPreferredProvider(cfg.Providers)
272+ cfg.Models = PreferredModels{
273+ Large: PreferredModel{
274+ ModelID: preferredProvider.DefaultLargeModel,
275+ Provider: preferredProvider.ID,
276+ },
277+ Small: PreferredModel{
278+ ModelID: preferredProvider.DefaultSmallModel,
279+ Provider: preferredProvider.ID,
280+ },
281+ }
282+
283+ mergeModels(cfg, globalCfg, localConfig)
284+
285+ if preferredProvider == nil {
286+ return nil, errors.New("no valid providers configured")
287+ }
288+
289+ agents := map[AgentID]Agent{
290+ AgentCoder: {
291+ ID: AgentCoder,
292+ Name: "Coder",
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
298+ },
299+ AgentTask: {
300+ ID: AgentTask,
301+ Name: "Task",
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{
307+ "glob",
308+ "grep",
309+ "ls",
310+ "sourcegraph",
311+ "view",
312+ },
313+ // NO MCPs or LSPs by default
314+ AllowedMCP: map[string][]string{},
315+ AllowedLSP: []string{},
316+ },
317+ }
318+ cfg.Agents = agents
319+ mergeAgents(cfg, globalCfg, localConfig)
320+ mergeMCPs(cfg, globalCfg, localConfig)
321+ mergeLSPs(cfg, globalCfg, localConfig)
280322
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)
287− }
288− if apiKey := os.Getenv("OPENAI_API_KEY"); apiKey != "" {
289− viper.SetDefault("providers.openai.apiKey", apiKey)
290− }
291− if apiKey := os.Getenv("GEMINI_API_KEY"); apiKey != "" {
292− viper.SetDefault("providers.gemini.apiKey", apiKey)
293− }
294− if apiKey := os.Getenv("GROQ_API_KEY"); apiKey != "" {
295− viper.SetDefault("providers.groq.apiKey", apiKey)
296− }
297− if apiKey := os.Getenv("OPENROUTER_API_KEY"); apiKey != "" {
298− viper.SetDefault("providers.openrouter.apiKey", apiKey)
299− }
300− if apiKey := os.Getenv("XAI_API_KEY"); apiKey != "" {
301− viper.SetDefault("providers.xai.apiKey", apiKey)
302− }
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"))
306− }
307−
308− // Use this order to set the default models
309− // 1. Anthropic
310− // 2. OpenAI
311− // 3. Google Gemini
312− // 4. Groq
313− // 5. OpenRouter
314− // 6. AWS Bedrock
315− // 7. Azure
316− // 8. Google Cloud VertexAI
317−
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
325− }
326−
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)
333− return
334− }
335−
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)
342− return
343− }
344−
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)
351− return
352− }
353−
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)
360− return
361− }
362−
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)
369− return
370− }
371−
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)
378− return
379− }
380−
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)
387− return
388− }
389−
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)
396− return
397− }
323+ return cfg, nil
398324}
399325
400−// hasAWSCredentials checks if AWS credentials are available in the environment.
401−func hasAWSCredentials() bool {
402− // Check for explicit AWS credentials
403− if os.Getenv("AWS_ACCESS_KEY_ID") != "" && os.Getenv("AWS_SECRET_ACCESS_KEY") != "" {
404− return true
405− }
406−
407− // Check for AWS profile
408− if os.Getenv("AWS_PROFILE") != "" || os.Getenv("AWS_DEFAULT_PROFILE") != "" {
409− return true
410− }
326+func Init(workingDir string, debug bool) (*Config, error) {
327+ var err error
328+ once.Do(func() {
329+ cwd = workingDir
330+ instance, err = loadConfig(cwd, debug)
331+ if err != nil {
332+ logging.Error("Failed to load config", "error", err)
333+ }
334+ })
411335
412− // Check for AWS region
413− if os.Getenv("AWS_REGION") != "" || os.Getenv("AWS_DEFAULT_REGION") != "" {
414− return true
415− }
336+ return instance, err
337+}
416338
417− // Check if running on EC2 with instance profile
418− if os.Getenv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI") != "" ||
419− os.Getenv("AWS_CONTAINER_CREDENTIALS_FULL_URI") != "" {
420− return true
339+func Get() *Config {
340+ if instance == nil {
341+ // TODO: Handle this better
342+ panic("Config not initialized. Call InitConfig first.")
421343 }
422−
423− return false
344+ return instance
424345}
425346
426−// hasVertexAICredentials checks if VertexAI credentials are available in the environment.
427−func hasVertexAICredentials() bool {
428− // Check for explicit VertexAI parameters
429− if os.Getenv("VERTEXAI_PROJECT") != "" && os.Getenv("VERTEXAI_LOCATION") != "" {
430− return true
347+func getPreferredProvider(configuredProviders map[provider.InferenceProvider]ProviderConfig) *ProviderConfig {
348+ providers := Providers()
349+ for _, p := range providers {
350+ if providerConfig, ok := configuredProviders[p.ID]; ok && !providerConfig.Disabled {
351+ return &providerConfig
352+ }
431353 }
432− // Check for Google Cloud project and location
433− if os.Getenv("GOOGLE_CLOUD_PROJECT") != "" && (os.Getenv("GOOGLE_CLOUD_REGION") != "" || os.Getenv("GOOGLE_CLOUD_LOCATION") != "") {
434− return true
354+ // if none found return the first configured provider
355+ for _, providerConfig := range configuredProviders {
356+ if !providerConfig.Disabled {
357+ return &providerConfig
358+ }
435359 }
436− return false
360+ return nil
437361}
438362
439−// readConfig handles the result of reading a configuration file.
440−func readConfig(err error) error {
441− if err == nil {
442− return nil
363+func mergeProviderConfig(p provider.InferenceProvider, base, other ProviderConfig) ProviderConfig {
364+ if other.APIKey != "" {
365+ base.APIKey = other.APIKey
443366 }
444−
445− // It's okay if the config file doesn't exist
446− if _, ok := err.(viper.ConfigFileNotFoundError); ok {
447− return nil
367+ // Only change these options if the provider is not a known provider
368+ if !slices.Contains(provider.KnownProviders(), p) {
369+ if other.BaseURL != "" {
370+ base.BaseURL = other.BaseURL
371+ }
372+ if other.ProviderType != "" {
373+ base.ProviderType = other.ProviderType
374+ }
375+ if len(base.ExtraHeaders) > 0 {
376+ if base.ExtraHeaders == nil {
377+ base.ExtraHeaders = make(map[string]string)
378+ }
379+ maps.Copy(base.ExtraHeaders, other.ExtraHeaders)
380+ }
381+ if len(other.ExtraParams) > 0 {
382+ if base.ExtraParams == nil {
383+ base.ExtraParams = make(map[string]string)
384+ }
385+ maps.Copy(base.ExtraParams, other.ExtraParams)
386+ }
448387 }
449388
450− return fmt.Errorf("failed to read config: %w", err)
451−}
452−
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)
459−
460− // Merge local config if it exists
461− if err := local.ReadInConfig(); err == nil {
462− viper.MergeConfigMap(local.AllSettings())
389+ if other.Disabled {
390+ base.Disabled = other.Disabled
463391 }
464−}
465392
466−// applyDefaultValues sets default values for configuration fields that need processing.
467−func applyDefaultValues() {
468− // Set default MCP type if not specified
469− for k, v := range cfg.MCPServers {
470− if v.Type == "" {
471− v.Type = MCPStdio
472− cfg.MCPServers[k] = v
473− }
393+ if other.DefaultLargeModel != "" {
394+ base.DefaultLargeModel = other.DefaultLargeModel
474395 }
475−}
476−
477−// It validates model IDs and providers, ensuring they are supported.
478−func validateAgent(cfg *Config, name AgentName, agent Agent) error {
479− // Check if model exists
480− model, modelExists := models.SupportedModels[agent.Model]
481− if !modelExists {
482− logging.Warn("unsupported model configured, reverting to default",
483− "agent", name,
484− "configured_model", agent.Model)
485−
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)
489− } else {
490− return fmt.Errorf("no valid provider available for agent %s", name)
491− }
492− return nil
493− }
494−
495− // Check if provider for the model is configured
496− provider := model.Provider
497− providerCfg, providerExists := cfg.Providers[provider]
498−
499− if !providerExists {
500− // Provider not configured, check if we have environment variables
501− apiKey := getProviderAPIKey(provider)
502− if apiKey == "" {
503− logging.Warn("provider not configured for model, reverting to default",
504− "agent", name,
505− "model", agent.Model,
506− "provider", provider)
507−
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)
511− } else {
512− return fmt.Errorf("no valid provider available for agent %s", name)
396+ // Add new models if they don't exist
397+ if other.Models != nil {
398+ for _, model := range other.Models {
399+ // check if the model already exists
400+ exists := false
401+ for _, existingModel := range base.Models {
402+ if existingModel.ID == model.ID {
403+ exists = true
404+ break
405+ }
513406 }
514− } else {
515− // Add provider with API key from environment
516− cfg.Providers[provider] = Provider{
517− APIKey: apiKey,
518− }
519− logging.Info("added provider from environment", "provider", provider)
520− }
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",
524− "agent", name,
525− "model", agent.Model,
526− "provider", provider)
527−
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)
531− } else {
532− return fmt.Errorf("no valid provider available for agent %s", name)
533− }
534− }
535−
536− // Validate max tokens
537− if agent.MaxTokens <= 0 {
538− logging.Warn("invalid max tokens, setting to default",
539− "agent", name,
540− "model", agent.Model,
541− "max_tokens", agent.MaxTokens)
542−
543− // Update the agent with default max tokens
544− updatedAgent := cfg.Agents[name]
545− if model.DefaultMaxTokens > 0 {
546− updatedAgent.MaxTokens = model.DefaultMaxTokens
547− } else {
548− updatedAgent.MaxTokens = MaxTokensFallbackDefault
549− }
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",
554− "agent", name,
555− "model", agent.Model,
556− "max_tokens", agent.MaxTokens,
557− "context_window", model.ContextWindow)
558−
559− // Update the agent with adjusted max tokens
560− updatedAgent := cfg.Agents[name]
561− updatedAgent.MaxTokens = model.ContextWindow / 2
562− cfg.Agents[name] = updatedAgent
563− }
564−
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",
570− "agent", name,
571− "model", agent.Model)
572−
573− // Update the agent with default reasoning effort
574− updatedAgent := cfg.Agents[name]
575− updatedAgent.ReasoningEffort = "medium"
576− cfg.Agents[name] = updatedAgent
577− } else {
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",
582− "agent", name,
583− "model", agent.Model,
584− "reasoning_effort", agent.ReasoningEffort)
585−
586− // Update the agent with valid reasoning effort
587− updatedAgent := cfg.Agents[name]
588− updatedAgent.ReasoningEffort = "medium"
589− cfg.Agents[name] = updatedAgent
407+ if !exists {
408+ base.Models = append(base.Models, model)
590409 }
591410 }
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",
595− "agent", name,
596− "model", agent.Model,
597− "reasoning_effort", agent.ReasoningEffort)
598−
599− // Update the agent to remove reasoning effort
600− updatedAgent := cfg.Agents[name]
601− updatedAgent.ReasoningEffort = ""
602− cfg.Agents[name] = updatedAgent
603411 }
604412
605− return nil
413+ return base
606414}
607415
608−// Validate checks if the configuration is valid and applies defaults where needed.
609−func Validate() error {
610− if cfg == nil {
611− return fmt.Errorf("config not loaded")
612− }
613−
614− // Validate agent models
615− for name, agent := range cfg.Agents {
616− if err := validateAgent(cfg, name, agent); err != nil {
617− return err
416+func validateProvider(p provider.InferenceProvider, providerConfig ProviderConfig) error {
417+ if !slices.Contains(provider.KnownProviders(), p) {
418+ if providerConfig.ProviderType != provider.TypeOpenAI {
419+ return errors.New("invalid provider type: " + string(providerConfig.ProviderType))
618420 }
619− }
620−
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
421+ if providerConfig.BaseURL == "" {
422+ return errors.New("base URL must be set for custom providers")
627423 }
628− }
629−
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
424+ if providerConfig.APIKey == "" {
425+ return errors.New("API key must be set for custom providers")
636426 }
637427 }
638−
639428 return nil
640429}
641430
642−// getProviderAPIKey gets the API key for a provider from environment variables
643−func getProviderAPIKey(provider models.InferenceProvider) string {
644− switch provider {
645− case models.ProviderAnthropic:
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"
660− }
661− case models.ProviderVertexAI:
662− if hasVertexAICredentials() {
663− return "vertex-ai-credentials-available"
664− }
665− }
666− return ""
667−}
668−
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 {
675− maxTokens = 80
431+func mergeModels(base, global, local *Config) {
432+ for _, cfg := range []*Config{global, local} {
433+ if cfg == nil {
434+ continue
676435 }
677− cfg.Agents[agent] = Agent{
678− Model: models.Claude37Sonnet,
679− MaxTokens: maxTokens,
436+ if cfg.Models.Large.ModelID != "" && cfg.Models.Large.Provider != "" {
437+ base.Models.Large = cfg.Models.Large
680438 }
681− return true
682− }
683439
684− if apiKey := os.Getenv("OPENAI_API_KEY"); apiKey != "" {
685− var model models.ModelID
686− maxTokens := int64(5000)
687− reasoningEffort := ""
688−
689− switch agent {
690− case AgentTitle:
691− model = models.GPT41Mini
692− maxTokens = 80
693− case AgentTask:
694− model = models.GPT41Mini
695− default:
696− model = models.GPT41
440+ if cfg.Models.Small.ModelID != "" && cfg.Models.Small.Provider != "" {
441+ base.Models.Small = cfg.Models.Small
697442 }
443+ }
444+}
698445
699− // Check if model supports reasoning
700− if modelInfo, ok := models.SupportedModels[model]; ok && modelInfo.CanReason {
701− reasoningEffort = "medium"
446+func mergeOptions(base, global, local *Config) {
447+ for _, cfg := range []*Config{global, local} {
448+ if cfg == nil {
449+ continue
450+ }
451+ baseOptions := base.Options
452+ other := cfg.Options
453+ if len(other.ContextPaths) > 0 {
454+ baseOptions.ContextPaths = append(baseOptions.ContextPaths, other.ContextPaths...)
702455 }
703456
704− cfg.Agents[agent] = Agent{
705− Model: model,
706− MaxTokens: maxTokens,
707− ReasoningEffort: reasoningEffort,
457+ if other.TUI.CompactMode {
458+ baseOptions.TUI.CompactMode = other.TUI.CompactMode
708459 }
709− return true
710− }
711460
712− if apiKey := os.Getenv("OPENROUTER_API_KEY"); apiKey != "" {
713− var model models.ModelID
714− maxTokens := int64(5000)
715− reasoningEffort := ""
461+ if other.Debug {
462+ baseOptions.Debug = other.Debug
463+ }
716464
717− switch agent {
718− case AgentTitle:
719− model = models.OpenRouterClaude35Haiku
720− maxTokens = 80
721− case AgentTask:
722− model = models.OpenRouterClaude37Sonnet
723− default:
724− model = models.OpenRouterClaude37Sonnet
465+ if other.DebugLSP {
466+ baseOptions.DebugLSP = other.DebugLSP
725467 }
726468
727− // Check if model supports reasoning
728− if modelInfo, ok := models.SupportedModels[model]; ok && modelInfo.CanReason {
729− reasoningEffort = "medium"
469+ if other.DisableAutoSummarize {
470+ baseOptions.DisableAutoSummarize = other.DisableAutoSummarize
730471 }
731472
732− cfg.Agents[agent] = Agent{
733− Model: model,
734− MaxTokens: maxTokens,
735− ReasoningEffort: reasoningEffort,
473+ if other.DataDirectory != "" {
474+ baseOptions.DataDirectory = other.DataDirectory
736475 }
737− return true
476+ base.Options = baseOptions
738477 }
478+}
739479
740− if apiKey := os.Getenv("GEMINI_API_KEY"); apiKey != "" {
741− var model models.ModelID
742− maxTokens := int64(5000)
743−
744− if agent == AgentTitle {
745− model = models.Gemini25Flash
746− maxTokens = 80
747− } else {
748− model = models.Gemini25
480+func mergeAgents(base, global, local *Config) {
481+ for _, cfg := range []*Config{global, local} {
482+ if cfg == nil {
483+ continue
749484 }
750−
751− cfg.Agents[agent] = Agent{
752− Model: model,
753− MaxTokens: maxTokens,
485+ for agentID, newAgent := range cfg.Agents {
486+ if _, ok := base.Agents[agentID]; !ok {
487+ newAgent.ID = agentID // Ensure the ID is set correctly
488+ base.Agents[agentID] = newAgent
489+ } else {
490+ switch agentID {
491+ case AgentCoder:
492+ baseAgent := base.Agents[agentID]
493+ if newAgent.Model != "" && newAgent.Provider != "" {
494+ baseAgent.Model = newAgent.Model
495+ baseAgent.Provider = newAgent.Provider
496+ }
497+ baseAgent.AllowedMCP = newAgent.AllowedMCP
498+ baseAgent.AllowedLSP = newAgent.AllowedLSP
499+ base.Agents[agentID] = baseAgent
500+ default:
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
508+ }
509+ baseAgent.AllowedTools = newAgent.AllowedTools
510+ baseAgent.AllowedMCP = newAgent.AllowedMCP
511+ baseAgent.AllowedLSP = newAgent.AllowedLSP
512+ base.Agents[agentID] = baseAgent
513+ }
514+ }
754515 }
755− return true
756516 }
517+}
757518
758− if apiKey := os.Getenv("GROQ_API_KEY"); apiKey != "" {
759− maxTokens := int64(5000)
760− if agent == AgentTitle {
761− maxTokens = 80
519+func mergeMCPs(base, global, local *Config) {
520+ for _, cfg := range []*Config{global, local} {
521+ if cfg == nil {
522+ continue
762523 }
524+ maps.Copy(base.MCP, cfg.MCP)
525+ }
526+}
763527
764− cfg.Agents[agent] = Agent{
765− Model: models.QWENQwq,
766− MaxTokens: maxTokens,
528+func mergeLSPs(base, global, local *Config) {
529+ for _, cfg := range []*Config{global, local} {
530+ if cfg == nil {
531+ continue
767532 }
768− return true
533+ maps.Copy(base.LSP, cfg.LSP)
769534 }
535+}
770536
771− if hasAWSCredentials() {
772− maxTokens := int64(5000)
773− if agent == AgentTitle {
774− maxTokens = 80
537+func mergeProviderConfigs(base, global, local *Config) {
538+ for _, cfg := range []*Config{global, local} {
539+ if cfg == nil {
540+ continue
775541 }
776−
777− cfg.Agents[agent] = Agent{
778− Model: models.BedrockClaude37Sonnet,
779− MaxTokens: maxTokens,
780− ReasoningEffort: "medium", // Claude models support reasoning
542+ for providerName, globalProvider := range cfg.Providers {
543+ if _, ok := base.Providers[providerName]; !ok {
544+ base.Providers[providerName] = globalProvider
545+ } else {
546+ base.Providers[providerName] = mergeProviderConfig(providerName, base.Providers[providerName], globalProvider)
547+ }
781548 }
782− return true
783549 }
784550
785− if hasVertexAICredentials() {
786− var model models.ModelID
787− maxTokens := int64(5000)
788−
789− if agent == AgentTitle {
790− model = models.VertexAIGemini25Flash
791− maxTokens = 80
792− } else {
793− model = models.VertexAIGemini25
551+ finalProviders := make(map[provider.InferenceProvider]ProviderConfig)
552+ for providerName, providerConfig := range base.Providers {
553+ err := validateProvider(providerName, providerConfig)
554+ if err != nil {
555+ logging.Warn("Skipping provider", "name", providerName, "error", err)
794556 }
557+ finalProviders[providerName] = providerConfig
558+ }
559+ base.Providers = finalProviders
560+}
795561
796− cfg.Agents[agent] = Agent{
797− Model: model,
798− MaxTokens: maxTokens,
562+func providerDefaultConfig(providerId provider.InferenceProvider) ProviderConfig {
563+ switch providerId {
564+ case provider.InferenceProviderAnthropic:
565+ return ProviderConfig{
566+ ID: providerId,
567+ ProviderType: provider.TypeAnthropic,
568+ }
569+ case provider.InferenceProviderOpenAI:
570+ return ProviderConfig{
571+ ID: providerId,
572+ ProviderType: provider.TypeOpenAI,
573+ }
574+ case provider.InferenceProviderGemini:
575+ return ProviderConfig{
576+ ID: providerId,
577+ ProviderType: provider.TypeGemini,
578+ }
579+ case provider.InferenceProviderBedrock:
580+ return ProviderConfig{
581+ ID: providerId,
582+ ProviderType: provider.TypeBedrock,
583+ }
584+ case provider.InferenceProviderAzure:
585+ return ProviderConfig{
586+ ID: providerId,
587+ ProviderType: provider.TypeAzure,
588+ }
589+ case provider.InferenceProviderOpenRouter:
590+ return ProviderConfig{
591+ ID: providerId,
592+ ProviderType: provider.TypeOpenAI,
593+ BaseURL: "https://openrouter.ai/api/v1",
594+ ExtraHeaders: map[string]string{
595+ "HTTP-Referer": "crush.charm.land",
596+ "X-Title": "Crush",
597+ },
598+ }
599+ case provider.InferenceProviderXAI:
600+ return ProviderConfig{
601+ ID: providerId,
602+ ProviderType: provider.TypeXAI,
603+ BaseURL: "https://api.x.ai/v1",
604+ }
605+ case provider.InferenceProviderVertexAI:
606+ return ProviderConfig{
607+ ID: providerId,
608+ ProviderType: provider.TypeVertexAI,
609+ }
610+ default:
611+ return ProviderConfig{
612+ ID: providerId,
613+ ProviderType: provider.TypeOpenAI,
799614 }
800− return true
801615 }
802−
803− return false
804616}
805617
806−func updateCfgFile(updateCfg func(config *Config)) error {
807− if cfg == nil {
808− return fmt.Errorf("config not loaded")
618+func defaultConfigBasedOnEnv() *Config {
619+ cfg := &Config{
620+ Options: Options{
621+ DataDirectory: defaultDataDirectory,
622+ ContextPaths: defaultContextPaths,
623+ },
624+ Providers: make(map[provider.InferenceProvider]ProviderConfig),
625+ }
626+
627+ providers := Providers()
628+
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 != "" {
641+ baseURL = url
642+ }
643+ }
644+ providerConfig.BaseURL = baseURL
645+ for _, model := range p.Models {
646+ providerConfig.Models = append(providerConfig.Models, Model{
647+ ID: model.ID,
648+ Name: model.Name,
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,
657+ })
658+ }
659+ cfg.Providers[p.ID] = providerConfig
660+ }
661+ }
809662 }
663+ // TODO: support local models
810664
811− // Get the config file path
812− configFile := viper.ConfigFileUsed()
813− var configData []byte
814− if configFile == "" {
815− homeDir, err := os.UserHomeDir()
816− if err != nil {
817− return fmt.Errorf("failed to get home directory: %w", err)
665+ if useVertexAI := os.Getenv("GOOGLE_GENAI_USE_VERTEXAI"); useVertexAI == "true" {
666+ providerConfig := providerDefaultConfig(provider.InferenceProviderVertexAI)
667+ providerConfig.ExtraParams = map[string]string{
668+ "project": os.Getenv("GOOGLE_CLOUD_PROJECT"),
669+ "location": os.Getenv("GOOGLE_CLOUD_LOCATION"),
818670 }
819− configFile = filepath.Join(homeDir, fmt.Sprintf(".%s.json", appName))
820− logging.Info("config file not found, creating new one", "path", configFile)
821− configData = []byte(`{}`)
822− } else {
823− // Read the existing config file
824− data, err := os.ReadFile(configFile)
825− if err != nil {
826− return fmt.Errorf("failed to read config file: %w", err)
827− }
828− configData = data
671+ cfg.Providers[provider.InferenceProviderVertexAI] = providerConfig
829672 }
830673
831− // Parse the JSON
832− var userCfg *Config
833− if err := json.Unmarshal(configData, &userCfg); err != nil {
834− return fmt.Errorf("failed to parse config file: %w", err)
674+ if hasAWSCredentials() {
675+ providerConfig := providerDefaultConfig(provider.InferenceProviderBedrock)
676+ providerConfig.ExtraParams = map[string]string{
677+ "region": os.Getenv("AWS_DEFAULT_REGION"),
678+ }
679+ if providerConfig.ExtraParams["region"] == "" {
680+ providerConfig.ExtraParams["region"] = os.Getenv("AWS_REGION")
681+ }
682+ cfg.Providers[provider.InferenceProviderBedrock] = providerConfig
835683 }
684+ return cfg
685+}
836686
837− updateCfg(userCfg)
687+func hasAWSCredentials() bool {
688+ if os.Getenv("AWS_ACCESS_KEY_ID") != "" && os.Getenv("AWS_SECRET_ACCESS_KEY") != "" {
689+ return true
690+ }
838691
839− // Write the updated config back to file
840− updatedData, err := json.MarshalIndent(userCfg, "", " ")
841− if err != nil {
842− return fmt.Errorf("failed to marshal config: %w", err)
692+ if os.Getenv("AWS_PROFILE") != "" || os.Getenv("AWS_DEFAULT_PROFILE") != "" {
693+ return true
843694 }
844695
845− if err := os.WriteFile(configFile, updatedData, 0o644); err != nil {
846− return fmt.Errorf("failed to write config file: %w", err)
696+ if os.Getenv("AWS_REGION") != "" || os.Getenv("AWS_DEFAULT_REGION") != "" {
697+ return true
847698 }
848699
849− return nil
850−}
700+ if os.Getenv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI") != "" ||
701+ os.Getenv("AWS_CONTAINER_CREDENTIALS_FULL_URI") != "" {
702+ return true
703+ }
851704
852−// Get returns the current configuration.
853−// It's safe to call this function multiple times.
854−func Get() *Config {
855− return cfg
705+ return false
856706}
857707
858−// WorkingDirectory returns the current working directory from the configuration.
859708func WorkingDirectory() string {
860− if cfg == nil {
861− panic("config not loaded")
862− }
863− return cfg.WorkingDir
709+ return cwd
864710}
865711
866−func UpdateAgentModel(agentName AgentName, modelID models.ModelID) error {
867− if cfg == nil {
868− panic("config not loaded")
712+func GetAgentModel(agentID AgentID) Model {
713+ cfg := Get()
714+ agent, ok := cfg.Agents[agentID]
715+ if !ok {
716+ logging.Error("Agent not found", "agent_id", agentID)
717+ return Model{}
869718 }
870719
871− existingAgentCfg := cfg.Agents[agentName]
872−
873− model, ok := models.SupportedModels[modelID]
720+ providerConfig, ok := cfg.Providers[agent.Provider]
874721 if !ok {
875− return fmt.Errorf("model %s not supported", modelID)
722+ logging.Error("Provider not found for agent", "agent_id", agentID, "provider", agent.Provider)
723+ return Model{}
876724 }
877725
878− maxTokens := existingAgentCfg.MaxTokens
879− if model.DefaultMaxTokens > 0 {
880− maxTokens = model.DefaultMaxTokens
726+ for _, model := range providerConfig.Models {
727+ if model.ID == agent.Model {
728+ return model
729+ }
881730 }
882731
883− newAgentCfg := Agent{
884− Model: modelID,
885− MaxTokens: maxTokens,
886− ReasoningEffort: existingAgentCfg.ReasoningEffort,
887− }
888− cfg.Agents[agentName] = newAgentCfg
732+ logging.Error("Model not found for agent", "agent_id", agentID, "model", agent.Model)
733+ return Model{}
734+}
889735
890− if err := validateAgent(cfg, agentName, newAgentCfg); err != nil {
891− // revert config update on failure
892− cfg.Agents[agentName] = existingAgentCfg
893− return fmt.Errorf("failed to update agent model: %w", err)
736+func GetProviderModel(provider provider.InferenceProvider, modelID string) Model {
737+ cfg := Get()
738+ providerConfig, ok := cfg.Providers[provider]
739+ if !ok {
740+ logging.Error("Provider not found", "provider", provider)
741+ return Model{}
894742 }
895743
896− return updateCfgFile(func(config *Config) {
897− if config.Agents == nil {
898− config.Agents = make(map[AgentName]Agent)
744+ for _, model := range providerConfig.Models {
745+ if model.ID == modelID {
746+ return model
899747 }
900− config.Agents[agentName] = newAgentCfg
901− })
902−}
903−
904−// UpdateTheme updates the theme in the configuration and writes it to the config file.
905−func UpdateTheme(themeName string) error {
906− if cfg == nil {
907− return fmt.Errorf("config not loaded")
908748 }
909749
910− // Update the in-memory config
911− cfg.TUI.Theme = themeName
912−
913− // Update the file config
914− return updateCfgFile(func(config *Config) {
915− config.TUI.Theme = themeName
916− })
750+ logging.Error("Model not found for provider", "provider", provider, "model_id", modelID)
751+ return Model{}
917752}