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}