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