config.go

  1// Package config manages application configuration from various sources.
  2package config
  3
  4import (
  5	"encoding/json"
  6	"fmt"
  7	"log/slog"
  8	"os"
  9	"path/filepath"
 10	"strings"
 11
 12	"github.com/charmbracelet/crush/internal/llm/models"
 13	"github.com/charmbracelet/crush/internal/logging"
 14	"github.com/spf13/afero"
 15	"github.com/spf13/viper"
 16)
 17
 18// MCPType defines the type of MCP (Model Control Protocol) server.
 19type MCPType string
 20
 21// Supported MCP types
 22const (
 23	MCPStdio MCPType = "stdio"
 24	MCPSse   MCPType = "sse"
 25)
 26
 27// MCPServer defines the configuration for a Model Control Protocol server.
 28type 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"`
 35}
 36
 37type AgentName string
 38
 39const (
 40	AgentCoder      AgentName = "coder"
 41	AgentSummarizer AgentName = "summarizer"
 42	AgentTask       AgentName = "task"
 43	AgentTitle      AgentName = "title"
 44)
 45
 46// Agent defines configuration for different LLM models and their token limits.
 47type Agent struct {
 48	Model           models.ModelID `json:"model"`
 49	MaxTokens       int64          `json:"maxTokens"`
 50	ReasoningEffort string         `json:"reasoningEffort"` // For openai models low,medium,heigh
 51}
 52
 53// Provider defines configuration for an LLM provider.
 54type Provider struct {
 55	APIKey   string `json:"apiKey"`
 56	Disabled bool   `json:"disabled"`
 57}
 58
 59// Data defines storage configuration.
 60type Data struct {
 61	Directory string `json:"directory,omitempty"`
 62}
 63
 64// LSPConfig defines configuration for Language Server Protocol integration.
 65type LSPConfig struct {
 66	Disabled bool     `json:"enabled"`
 67	Command  string   `json:"command"`
 68	Args     []string `json:"args"`
 69	Options  any      `json:"options"`
 70}
 71
 72// TUIConfig defines the configuration for the Terminal User Interface.
 73type TUIConfig struct {
 74	Theme string `json:"theme,omitempty"`
 75}
 76
 77// Config is the main configuration structure for the application.
 78type 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.ModelProvider]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"`
 90}
 91
 92// Application constants
 93const (
 94	defaultDataDirectory = ".crush"
 95	defaultLogLevel      = "info"
 96	appName              = "crush"
 97
 98	MaxTokensFallbackDefault = 4096
 99)
100
101var defaultContextPaths = []string{
102	".github/copilot-instructions.md",
103	".cursorrules",
104	".cursor/rules/",
105	"CLAUDE.md",
106	"CLAUDE.local.md",
107	"crush.md",
108	"crush.local.md",
109	"Crush.md",
110	"Crush.local.md",
111	"CRUSH.md",
112	"CRUSH.local.md",
113}
114
115// Global configuration instance
116var cfg *Config
117
118// Load initializes the configuration from environment variables and config files.
119// If debug is true, debug mode is enabled and log level is set to debug.
120// It returns an error if configuration loading fails.
121func Load(workingDir string, debug bool) (*Config, error) {
122	if cfg != nil {
123		return cfg, nil
124	}
125
126	cfg = &Config{
127		WorkingDir: workingDir,
128		MCPServers: make(map[string]MCPServer),
129		Providers:  make(map[models.ModelProvider]Provider),
130		LSP:        make(map[string]LSPConfig),
131	}
132
133	configureViper()
134	setDefaults(debug)
135
136	// Read global config
137	if err := readConfig(viper.ReadInConfig()); err != nil {
138		return cfg, err
139	}
140
141	// Load and merge local config
142	mergeLocalConfig(workingDir)
143
144	setProviderDefaults()
145
146	// Apply configuration to the struct
147	if err := viper.Unmarshal(cfg); err != nil {
148		return cfg, fmt.Errorf("failed to unmarshal config: %w", err)
149	}
150
151	applyDefaultValues()
152	defaultLevel := slog.LevelInfo
153	if cfg.Debug {
154		defaultLevel = slog.LevelDebug
155	}
156	if os.Getenv("CRUSH_DEV_DEBUG") == "true" {
157		loggingFile := fmt.Sprintf("%s/%s", cfg.Data.Directory, "debug.log")
158
159		// if file does not exist create it
160		if _, err := os.Stat(loggingFile); os.IsNotExist(err) {
161			if err := os.MkdirAll(cfg.Data.Directory, 0o755); err != nil {
162				return cfg, fmt.Errorf("failed to create directory: %w", err)
163			}
164			if _, err := os.Create(loggingFile); err != nil {
165				return cfg, fmt.Errorf("failed to create log file: %w", err)
166			}
167		}
168
169		sloggingFileWriter, err := os.OpenFile(loggingFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o666)
170		if err != nil {
171			return cfg, fmt.Errorf("failed to open log file: %w", err)
172		}
173		// Configure logger
174		logger := slog.New(slog.NewTextHandler(sloggingFileWriter, &slog.HandlerOptions{
175			Level: defaultLevel,
176		}))
177		slog.SetDefault(logger)
178	} else {
179		// Configure logger
180		logger := slog.New(slog.NewTextHandler(logging.NewWriter(), &slog.HandlerOptions{
181			Level: defaultLevel,
182		}))
183		slog.SetDefault(logger)
184	}
185
186	// Validate configuration
187	if err := Validate(); err != nil {
188		return cfg, fmt.Errorf("config validation failed: %w", err)
189	}
190
191	if cfg.Agents == nil {
192		cfg.Agents = make(map[AgentName]Agent)
193	}
194
195	// Override the max tokens for title agent
196	cfg.Agents[AgentTitle] = Agent{
197		Model:     cfg.Agents[AgentTitle].Model,
198		MaxTokens: 80,
199	}
200	return cfg, nil
201}
202
203type configFinder struct {
204	appName   string
205	dotPrefix bool
206	paths     []string
207}
208
209func (f configFinder) Find(fsys afero.Fs) ([]string, error) {
210	var configFiles []string
211	configName := fmt.Sprintf("%s.json", f.appName)
212	if f.dotPrefix {
213		configName = fmt.Sprintf(".%s.json", f.appName)
214	}
215	paths := []string{}
216	for _, p := range f.paths {
217		if p == "" {
218			continue
219		}
220		paths = append(paths, os.ExpandEnv(p))
221	}
222
223	for _, path := range paths {
224		if path == "" {
225			continue
226		}
227
228		configPath := filepath.Join(path, configName)
229		if exists, err := afero.Exists(fsys, configPath); err == nil && exists {
230			configFiles = append(configFiles, configPath)
231		}
232	}
233	return configFiles, nil
234}
235
236// configureViper sets up viper's configuration paths and environment variables.
237func configureViper() {
238	viper.SetConfigType("json")
239
240	// Create the three finders
241	windowsFinder := configFinder{appName: appName, dotPrefix: false, paths: []string{
242		"$USERPROFILE",
243		fmt.Sprintf("$APPDATA/%s", appName),
244		fmt.Sprintf("$LOCALAPPDATA/%s", appName),
245	}}
246
247	unixFinder := configFinder{appName: appName, dotPrefix: false, paths: []string{
248		"$HOME",
249		fmt.Sprintf("$XDG_CONFIG_HOME/%s", appName),
250		fmt.Sprintf("$HOME/.config/%s", appName),
251	}}
252
253	localFinder := configFinder{appName: appName, dotPrefix: true, paths: []string{
254		".",
255	}}
256
257	// Use all finders with viper
258	viper.SetOptions(viper.WithFinder(viper.Finders(windowsFinder, unixFinder, localFinder)))
259	viper.SetEnvPrefix(strings.ToUpper(appName))
260	viper.AutomaticEnv()
261}
262
263// setDefaults configures default values for configuration options.
264func setDefaults(debug bool) {
265	viper.SetDefault("data.directory", defaultDataDirectory)
266	viper.SetDefault("contextPaths", defaultContextPaths)
267	viper.SetDefault("tui.theme", "crush")
268	viper.SetDefault("autoCompact", true)
269
270	if debug {
271		viper.SetDefault("debug", true)
272		viper.Set("log.level", "debug")
273	} else {
274		viper.SetDefault("debug", false)
275		viper.SetDefault("log.level", defaultLogLevel)
276	}
277}
278
279// setProviderDefaults configures LLM provider defaults based on provider provided by
280// environment variables and configuration file.
281func setProviderDefaults() {
282	// Set all API keys we can find in the environment
283	if apiKey := os.Getenv("ANTHROPIC_API_KEY"); apiKey != "" {
284		viper.SetDefault("providers.anthropic.apiKey", apiKey)
285	}
286	if apiKey := os.Getenv("OPENAI_API_KEY"); apiKey != "" {
287		viper.SetDefault("providers.openai.apiKey", apiKey)
288	}
289	if apiKey := os.Getenv("GEMINI_API_KEY"); apiKey != "" {
290		viper.SetDefault("providers.gemini.apiKey", apiKey)
291	}
292	if apiKey := os.Getenv("GROQ_API_KEY"); apiKey != "" {
293		viper.SetDefault("providers.groq.apiKey", apiKey)
294	}
295	if apiKey := os.Getenv("OPENROUTER_API_KEY"); apiKey != "" {
296		viper.SetDefault("providers.openrouter.apiKey", apiKey)
297	}
298	if apiKey := os.Getenv("XAI_API_KEY"); apiKey != "" {
299		viper.SetDefault("providers.xai.apiKey", apiKey)
300	}
301	if apiKey := os.Getenv("AZURE_OPENAI_ENDPOINT"); apiKey != "" {
302		// api-key may be empty when using Entra ID credentials – that's okay
303		viper.SetDefault("providers.azure.apiKey", os.Getenv("AZURE_OPENAI_API_KEY"))
304	}
305
306	// Use this order to set the default models
307	// 1. Anthropic
308	// 2. OpenAI
309	// 3. Google Gemini
310	// 4. Groq
311	// 5. OpenRouter
312	// 6. AWS Bedrock
313	// 7. Azure
314	// 8. Google Cloud VertexAI
315
316	// Anthropic configuration
317	if key := viper.GetString("providers.anthropic.apiKey"); strings.TrimSpace(key) != "" {
318		viper.SetDefault("agents.coder.model", models.Claude4Sonnet)
319		viper.SetDefault("agents.summarizer.model", models.Claude4Sonnet)
320		viper.SetDefault("agents.task.model", models.Claude4Sonnet)
321		viper.SetDefault("agents.title.model", models.Claude4Sonnet)
322		return
323	}
324
325	// OpenAI configuration
326	if key := viper.GetString("providers.openai.apiKey"); strings.TrimSpace(key) != "" {
327		viper.SetDefault("agents.coder.model", models.GPT41)
328		viper.SetDefault("agents.summarizer.model", models.GPT41)
329		viper.SetDefault("agents.task.model", models.GPT41Mini)
330		viper.SetDefault("agents.title.model", models.GPT41Mini)
331		return
332	}
333
334	// Google Gemini configuration
335	if key := viper.GetString("providers.gemini.apiKey"); strings.TrimSpace(key) != "" {
336		viper.SetDefault("agents.coder.model", models.Gemini25)
337		viper.SetDefault("agents.summarizer.model", models.Gemini25)
338		viper.SetDefault("agents.task.model", models.Gemini25Flash)
339		viper.SetDefault("agents.title.model", models.Gemini25Flash)
340		return
341	}
342
343	// Groq configuration
344	if key := viper.GetString("providers.groq.apiKey"); strings.TrimSpace(key) != "" {
345		viper.SetDefault("agents.coder.model", models.QWENQwq)
346		viper.SetDefault("agents.summarizer.model", models.QWENQwq)
347		viper.SetDefault("agents.task.model", models.QWENQwq)
348		viper.SetDefault("agents.title.model", models.QWENQwq)
349		return
350	}
351
352	// OpenRouter configuration
353	if key := viper.GetString("providers.openrouter.apiKey"); strings.TrimSpace(key) != "" {
354		viper.SetDefault("agents.coder.model", models.OpenRouterClaude37Sonnet)
355		viper.SetDefault("agents.summarizer.model", models.OpenRouterClaude37Sonnet)
356		viper.SetDefault("agents.task.model", models.OpenRouterClaude37Sonnet)
357		viper.SetDefault("agents.title.model", models.OpenRouterClaude35Haiku)
358		return
359	}
360
361	// XAI configuration
362	if key := viper.GetString("providers.xai.apiKey"); strings.TrimSpace(key) != "" {
363		viper.SetDefault("agents.coder.model", models.XAIGrok3Beta)
364		viper.SetDefault("agents.summarizer.model", models.XAIGrok3Beta)
365		viper.SetDefault("agents.task.model", models.XAIGrok3Beta)
366		viper.SetDefault("agents.title.model", models.XAiGrok3MiniFastBeta)
367		return
368	}
369
370	// AWS Bedrock configuration
371	if hasAWSCredentials() {
372		viper.SetDefault("agents.coder.model", models.BedrockClaude37Sonnet)
373		viper.SetDefault("agents.summarizer.model", models.BedrockClaude37Sonnet)
374		viper.SetDefault("agents.task.model", models.BedrockClaude37Sonnet)
375		viper.SetDefault("agents.title.model", models.BedrockClaude37Sonnet)
376		return
377	}
378
379	// Azure OpenAI configuration
380	if os.Getenv("AZURE_OPENAI_ENDPOINT") != "" {
381		viper.SetDefault("agents.coder.model", models.AzureGPT41)
382		viper.SetDefault("agents.summarizer.model", models.AzureGPT41)
383		viper.SetDefault("agents.task.model", models.AzureGPT41Mini)
384		viper.SetDefault("agents.title.model", models.AzureGPT41Mini)
385		return
386	}
387
388	// Google Cloud VertexAI configuration
389	if hasVertexAICredentials() {
390		viper.SetDefault("agents.coder.model", models.VertexAIGemini25)
391		viper.SetDefault("agents.summarizer.model", models.VertexAIGemini25)
392		viper.SetDefault("agents.task.model", models.VertexAIGemini25Flash)
393		viper.SetDefault("agents.title.model", models.VertexAIGemini25Flash)
394		return
395	}
396}
397
398// hasAWSCredentials checks if AWS credentials are available in the environment.
399func hasAWSCredentials() bool {
400	// Check for explicit AWS credentials
401	if os.Getenv("AWS_ACCESS_KEY_ID") != "" && os.Getenv("AWS_SECRET_ACCESS_KEY") != "" {
402		return true
403	}
404
405	// Check for AWS profile
406	if os.Getenv("AWS_PROFILE") != "" || os.Getenv("AWS_DEFAULT_PROFILE") != "" {
407		return true
408	}
409
410	// Check for AWS region
411	if os.Getenv("AWS_REGION") != "" || os.Getenv("AWS_DEFAULT_REGION") != "" {
412		return true
413	}
414
415	// Check if running on EC2 with instance profile
416	if os.Getenv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI") != "" ||
417		os.Getenv("AWS_CONTAINER_CREDENTIALS_FULL_URI") != "" {
418		return true
419	}
420
421	return false
422}
423
424// hasVertexAICredentials checks if VertexAI credentials are available in the environment.
425func hasVertexAICredentials() bool {
426	// Check for explicit VertexAI parameters
427	if os.Getenv("VERTEXAI_PROJECT") != "" && os.Getenv("VERTEXAI_LOCATION") != "" {
428		return true
429	}
430	// Check for Google Cloud project and location
431	if os.Getenv("GOOGLE_CLOUD_PROJECT") != "" && (os.Getenv("GOOGLE_CLOUD_REGION") != "" || os.Getenv("GOOGLE_CLOUD_LOCATION") != "") {
432		return true
433	}
434	return false
435}
436
437// readConfig handles the result of reading a configuration file.
438func readConfig(err error) error {
439	if err == nil {
440		return nil
441	}
442
443	// It's okay if the config file doesn't exist
444	if _, ok := err.(viper.ConfigFileNotFoundError); ok {
445		return nil
446	}
447
448	return fmt.Errorf("failed to read config: %w", err)
449}
450
451// mergeLocalConfig loads and merges configuration from the local directory.
452func mergeLocalConfig(workingDir string) {
453	local := viper.New()
454	local.SetConfigName(fmt.Sprintf(".%s", appName))
455	local.SetConfigType("json")
456	local.AddConfigPath(workingDir)
457
458	// Merge local config if it exists
459	if err := local.ReadInConfig(); err == nil {
460		viper.MergeConfigMap(local.AllSettings())
461	}
462}
463
464// applyDefaultValues sets default values for configuration fields that need processing.
465func applyDefaultValues() {
466	// Set default MCP type if not specified
467	for k, v := range cfg.MCPServers {
468		if v.Type == "" {
469			v.Type = MCPStdio
470			cfg.MCPServers[k] = v
471		}
472	}
473}
474
475// It validates model IDs and providers, ensuring they are supported.
476func validateAgent(cfg *Config, name AgentName, agent Agent) error {
477	// Check if model exists
478	model, modelExists := models.SupportedModels[agent.Model]
479	if !modelExists {
480		logging.Warn("unsupported model configured, reverting to default",
481			"agent", name,
482			"configured_model", agent.Model)
483
484		// Set default model based on available providers
485		if setDefaultModelForAgent(name) {
486			logging.Info("set default model for agent", "agent", name, "model", cfg.Agents[name].Model)
487		} else {
488			return fmt.Errorf("no valid provider available for agent %s", name)
489		}
490		return nil
491	}
492
493	// Check if provider for the model is configured
494	provider := model.Provider
495	providerCfg, providerExists := cfg.Providers[provider]
496
497	if !providerExists {
498		// Provider not configured, check if we have environment variables
499		apiKey := getProviderAPIKey(provider)
500		if apiKey == "" {
501			logging.Warn("provider not configured for model, reverting to default",
502				"agent", name,
503				"model", agent.Model,
504				"provider", provider)
505
506			// Set default model based on available providers
507			if setDefaultModelForAgent(name) {
508				logging.Info("set default model for agent", "agent", name, "model", cfg.Agents[name].Model)
509			} else {
510				return fmt.Errorf("no valid provider available for agent %s", name)
511			}
512		} else {
513			// Add provider with API key from environment
514			cfg.Providers[provider] = Provider{
515				APIKey: apiKey,
516			}
517			logging.Info("added provider from environment", "provider", provider)
518		}
519	} else if providerCfg.Disabled || providerCfg.APIKey == "" {
520		// Provider is disabled or has no API key
521		logging.Warn("provider is disabled or has no API key, reverting to default",
522			"agent", name,
523			"model", agent.Model,
524			"provider", provider)
525
526		// Set default model based on available providers
527		if setDefaultModelForAgent(name) {
528			logging.Info("set default model for agent", "agent", name, "model", cfg.Agents[name].Model)
529		} else {
530			return fmt.Errorf("no valid provider available for agent %s", name)
531		}
532	}
533
534	// Validate max tokens
535	if agent.MaxTokens <= 0 {
536		logging.Warn("invalid max tokens, setting to default",
537			"agent", name,
538			"model", agent.Model,
539			"max_tokens", agent.MaxTokens)
540
541		// Update the agent with default max tokens
542		updatedAgent := cfg.Agents[name]
543		if model.DefaultMaxTokens > 0 {
544			updatedAgent.MaxTokens = model.DefaultMaxTokens
545		} else {
546			updatedAgent.MaxTokens = MaxTokensFallbackDefault
547		}
548		cfg.Agents[name] = updatedAgent
549	} else if model.ContextWindow > 0 && agent.MaxTokens > model.ContextWindow/2 {
550		// Ensure max tokens doesn't exceed half the context window (reasonable limit)
551		logging.Warn("max tokens exceeds half the context window, adjusting",
552			"agent", name,
553			"model", agent.Model,
554			"max_tokens", agent.MaxTokens,
555			"context_window", model.ContextWindow)
556
557		// Update the agent with adjusted max tokens
558		updatedAgent := cfg.Agents[name]
559		updatedAgent.MaxTokens = model.ContextWindow / 2
560		cfg.Agents[name] = updatedAgent
561	}
562
563	// Validate reasoning effort for models that support reasoning
564	if model.CanReason && provider == models.ProviderOpenAI || provider == models.ProviderLocal {
565		if agent.ReasoningEffort == "" {
566			// Set default reasoning effort for models that support it
567			logging.Info("setting default reasoning effort for model that supports reasoning",
568				"agent", name,
569				"model", agent.Model)
570
571			// Update the agent with default reasoning effort
572			updatedAgent := cfg.Agents[name]
573			updatedAgent.ReasoningEffort = "medium"
574			cfg.Agents[name] = updatedAgent
575		} else {
576			// Check if reasoning effort is valid (low, medium, high)
577			effort := strings.ToLower(agent.ReasoningEffort)
578			if effort != "low" && effort != "medium" && effort != "high" {
579				logging.Warn("invalid reasoning effort, setting to medium",
580					"agent", name,
581					"model", agent.Model,
582					"reasoning_effort", agent.ReasoningEffort)
583
584				// Update the agent with valid reasoning effort
585				updatedAgent := cfg.Agents[name]
586				updatedAgent.ReasoningEffort = "medium"
587				cfg.Agents[name] = updatedAgent
588			}
589		}
590	} else if !model.CanReason && agent.ReasoningEffort != "" {
591		// Model doesn't support reasoning but reasoning effort is set
592		logging.Warn("model doesn't support reasoning but reasoning effort is set, ignoring",
593			"agent", name,
594			"model", agent.Model,
595			"reasoning_effort", agent.ReasoningEffort)
596
597		// Update the agent to remove reasoning effort
598		updatedAgent := cfg.Agents[name]
599		updatedAgent.ReasoningEffort = ""
600		cfg.Agents[name] = updatedAgent
601	}
602
603	return nil
604}
605
606// Validate checks if the configuration is valid and applies defaults where needed.
607func Validate() error {
608	if cfg == nil {
609		return fmt.Errorf("config not loaded")
610	}
611
612	// Validate agent models
613	for name, agent := range cfg.Agents {
614		if err := validateAgent(cfg, name, agent); err != nil {
615			return err
616		}
617	}
618
619	// Validate providers
620	for provider, providerCfg := range cfg.Providers {
621		if providerCfg.APIKey == "" && !providerCfg.Disabled {
622			logging.Warn("provider has no API key, marking as disabled", "provider", provider)
623			providerCfg.Disabled = true
624			cfg.Providers[provider] = providerCfg
625		}
626	}
627
628	// Validate LSP configurations
629	for language, lspConfig := range cfg.LSP {
630		if lspConfig.Command == "" && !lspConfig.Disabled {
631			logging.Warn("LSP configuration has no command, marking as disabled", "language", language)
632			lspConfig.Disabled = true
633			cfg.LSP[language] = lspConfig
634		}
635	}
636
637	return nil
638}
639
640// getProviderAPIKey gets the API key for a provider from environment variables
641func getProviderAPIKey(provider models.ModelProvider) string {
642	switch provider {
643	case models.ProviderAnthropic:
644		return os.Getenv("ANTHROPIC_API_KEY")
645	case models.ProviderOpenAI:
646		return os.Getenv("OPENAI_API_KEY")
647	case models.ProviderGemini:
648		return os.Getenv("GEMINI_API_KEY")
649	case models.ProviderGROQ:
650		return os.Getenv("GROQ_API_KEY")
651	case models.ProviderAzure:
652		return os.Getenv("AZURE_OPENAI_API_KEY")
653	case models.ProviderOpenRouter:
654		return os.Getenv("OPENROUTER_API_KEY")
655	case models.ProviderBedrock:
656		if hasAWSCredentials() {
657			return "aws-credentials-available"
658		}
659	case models.ProviderVertexAI:
660		if hasVertexAICredentials() {
661			return "vertex-ai-credentials-available"
662		}
663	}
664	return ""
665}
666
667// setDefaultModelForAgent sets a default model for an agent based on available providers
668func setDefaultModelForAgent(agent AgentName) bool {
669	// Check providers in order of preference
670	if apiKey := os.Getenv("ANTHROPIC_API_KEY"); apiKey != "" {
671		maxTokens := int64(5000)
672		if agent == AgentTitle {
673			maxTokens = 80
674		}
675		cfg.Agents[agent] = Agent{
676			Model:     models.Claude37Sonnet,
677			MaxTokens: maxTokens,
678		}
679		return true
680	}
681
682	if apiKey := os.Getenv("OPENAI_API_KEY"); apiKey != "" {
683		var model models.ModelID
684		maxTokens := int64(5000)
685		reasoningEffort := ""
686
687		switch agent {
688		case AgentTitle:
689			model = models.GPT41Mini
690			maxTokens = 80
691		case AgentTask:
692			model = models.GPT41Mini
693		default:
694			model = models.GPT41
695		}
696
697		// Check if model supports reasoning
698		if modelInfo, ok := models.SupportedModels[model]; ok && modelInfo.CanReason {
699			reasoningEffort = "medium"
700		}
701
702		cfg.Agents[agent] = Agent{
703			Model:           model,
704			MaxTokens:       maxTokens,
705			ReasoningEffort: reasoningEffort,
706		}
707		return true
708	}
709
710	if apiKey := os.Getenv("OPENROUTER_API_KEY"); apiKey != "" {
711		var model models.ModelID
712		maxTokens := int64(5000)
713		reasoningEffort := ""
714
715		switch agent {
716		case AgentTitle:
717			model = models.OpenRouterClaude35Haiku
718			maxTokens = 80
719		case AgentTask:
720			model = models.OpenRouterClaude37Sonnet
721		default:
722			model = models.OpenRouterClaude37Sonnet
723		}
724
725		// Check if model supports reasoning
726		if modelInfo, ok := models.SupportedModels[model]; ok && modelInfo.CanReason {
727			reasoningEffort = "medium"
728		}
729
730		cfg.Agents[agent] = Agent{
731			Model:           model,
732			MaxTokens:       maxTokens,
733			ReasoningEffort: reasoningEffort,
734		}
735		return true
736	}
737
738	if apiKey := os.Getenv("GEMINI_API_KEY"); apiKey != "" {
739		var model models.ModelID
740		maxTokens := int64(5000)
741
742		if agent == AgentTitle {
743			model = models.Gemini25Flash
744			maxTokens = 80
745		} else {
746			model = models.Gemini25
747		}
748
749		cfg.Agents[agent] = Agent{
750			Model:     model,
751			MaxTokens: maxTokens,
752		}
753		return true
754	}
755
756	if apiKey := os.Getenv("GROQ_API_KEY"); apiKey != "" {
757		maxTokens := int64(5000)
758		if agent == AgentTitle {
759			maxTokens = 80
760		}
761
762		cfg.Agents[agent] = Agent{
763			Model:     models.QWENQwq,
764			MaxTokens: maxTokens,
765		}
766		return true
767	}
768
769	if hasAWSCredentials() {
770		maxTokens := int64(5000)
771		if agent == AgentTitle {
772			maxTokens = 80
773		}
774
775		cfg.Agents[agent] = Agent{
776			Model:           models.BedrockClaude37Sonnet,
777			MaxTokens:       maxTokens,
778			ReasoningEffort: "medium", // Claude models support reasoning
779		}
780		return true
781	}
782
783	if hasVertexAICredentials() {
784		var model models.ModelID
785		maxTokens := int64(5000)
786
787		if agent == AgentTitle {
788			model = models.VertexAIGemini25Flash
789			maxTokens = 80
790		} else {
791			model = models.VertexAIGemini25
792		}
793
794		cfg.Agents[agent] = Agent{
795			Model:     model,
796			MaxTokens: maxTokens,
797		}
798		return true
799	}
800
801	return false
802}
803
804func updateCfgFile(updateCfg func(config *Config)) error {
805	if cfg == nil {
806		return fmt.Errorf("config not loaded")
807	}
808
809	// Get the config file path
810	configFile := viper.ConfigFileUsed()
811	var configData []byte
812	if configFile == "" {
813		homeDir, err := os.UserHomeDir()
814		if err != nil {
815			return fmt.Errorf("failed to get home directory: %w", err)
816		}
817		configFile = filepath.Join(homeDir, fmt.Sprintf(".%s.json", appName))
818		logging.Info("config file not found, creating new one", "path", configFile)
819		configData = []byte(`{}`)
820	} else {
821		// Read the existing config file
822		data, err := os.ReadFile(configFile)
823		if err != nil {
824			return fmt.Errorf("failed to read config file: %w", err)
825		}
826		configData = data
827	}
828
829	// Parse the JSON
830	var userCfg *Config
831	if err := json.Unmarshal(configData, &userCfg); err != nil {
832		return fmt.Errorf("failed to parse config file: %w", err)
833	}
834
835	updateCfg(userCfg)
836
837	// Write the updated config back to file
838	updatedData, err := json.MarshalIndent(userCfg, "", "  ")
839	if err != nil {
840		return fmt.Errorf("failed to marshal config: %w", err)
841	}
842
843	if err := os.WriteFile(configFile, updatedData, 0o644); err != nil {
844		return fmt.Errorf("failed to write config file: %w", err)
845	}
846
847	return nil
848}
849
850// Get returns the current configuration.
851// It's safe to call this function multiple times.
852func Get() *Config {
853	return cfg
854}
855
856// WorkingDirectory returns the current working directory from the configuration.
857func WorkingDirectory() string {
858	if cfg == nil {
859		panic("config not loaded")
860	}
861	return cfg.WorkingDir
862}
863
864func UpdateAgentModel(agentName AgentName, modelID models.ModelID) error {
865	if cfg == nil {
866		panic("config not loaded")
867	}
868
869	existingAgentCfg := cfg.Agents[agentName]
870
871	model, ok := models.SupportedModels[modelID]
872	if !ok {
873		return fmt.Errorf("model %s not supported", modelID)
874	}
875
876	maxTokens := existingAgentCfg.MaxTokens
877	if model.DefaultMaxTokens > 0 {
878		maxTokens = model.DefaultMaxTokens
879	}
880
881	newAgentCfg := Agent{
882		Model:           modelID,
883		MaxTokens:       maxTokens,
884		ReasoningEffort: existingAgentCfg.ReasoningEffort,
885	}
886	cfg.Agents[agentName] = newAgentCfg
887
888	if err := validateAgent(cfg, agentName, newAgentCfg); err != nil {
889		// revert config update on failure
890		cfg.Agents[agentName] = existingAgentCfg
891		return fmt.Errorf("failed to update agent model: %w", err)
892	}
893
894	return updateCfgFile(func(config *Config) {
895		if config.Agents == nil {
896			config.Agents = make(map[AgentName]Agent)
897		}
898		config.Agents[agentName] = newAgentCfg
899	})
900}
901
902// UpdateTheme updates the theme in the configuration and writes it to the config file.
903func UpdateTheme(themeName string) error {
904	if cfg == nil {
905		return fmt.Errorf("config not loaded")
906	}
907
908	// Update the in-memory config
909	cfg.TUI.Theme = themeName
910
911	// Update the file config
912	return updateCfgFile(func(config *Config) {
913		config.TUI.Theme = themeName
914	})
915}