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