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