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