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}