config.go

  1// Package config manages application configuration from various sources.
  2package config
  3
  4import (
  5	"encoding/json"
  6	"fmt"
  7	"log/slog"
  8	"os"
  9	"path/filepath"
 10	"strings"
 11
 12	"github.com/opencode-ai/opencode/internal/llm/models"
 13	"github.com/opencode-ai/opencode/internal/logging"
 14	"github.com/spf13/viper"
 15)
 16
 17// MCPType defines the type of MCP (Model Control Protocol) server.
 18type MCPType string
 19
 20// Supported MCP types
 21const (
 22	MCPStdio MCPType = "stdio"
 23	MCPSse   MCPType = "sse"
 24)
 25
 26// MCPServer defines the configuration for a Model Control Protocol server.
 27type MCPServer struct {
 28	Command string            `json:"command"`
 29	Env     []string          `json:"env"`
 30	Args    []string          `json:"args"`
 31	Type    MCPType           `json:"type"`
 32	URL     string            `json:"url"`
 33	Headers map[string]string `json:"headers"`
 34}
 35
 36type AgentName string
 37
 38const (
 39	AgentCoder AgentName = "coder"
 40	AgentTask  AgentName = "task"
 41	AgentTitle AgentName = "title"
 42)
 43
 44// Agent defines configuration for different LLM models and their token limits.
 45type Agent struct {
 46	Model           models.ModelID `json:"model"`
 47	MaxTokens       int64          `json:"maxTokens"`
 48	ReasoningEffort string         `json:"reasoningEffort"` // For openai models low,medium,heigh
 49}
 50
 51// Provider defines configuration for an LLM provider.
 52type Provider struct {
 53	APIKey   string `json:"apiKey"`
 54	Disabled bool   `json:"disabled"`
 55}
 56
 57// Data defines storage configuration.
 58type Data struct {
 59	Directory string `json:"directory"`
 60}
 61
 62// LSPConfig defines configuration for Language Server Protocol integration.
 63type LSPConfig struct {
 64	Disabled bool     `json:"enabled"`
 65	Command  string   `json:"command"`
 66	Args     []string `json:"args"`
 67	Options  any      `json:"options"`
 68}
 69
 70// TUIConfig defines the configuration for the Terminal User Interface.
 71type TUIConfig struct {
 72	Theme string `json:"theme,omitempty"`
 73}
 74
 75// Config is the main configuration structure for the application.
 76type Config struct {
 77	Data         Data                              `json:"data"`
 78	WorkingDir   string                            `json:"wd,omitempty"`
 79	MCPServers   map[string]MCPServer              `json:"mcpServers,omitempty"`
 80	Providers    map[models.ModelProvider]Provider `json:"providers,omitempty"`
 81	LSP          map[string]LSPConfig              `json:"lsp,omitempty"`
 82	Agents       map[AgentName]Agent               `json:"agents"`
 83	Debug        bool                              `json:"debug,omitempty"`
 84	DebugLSP     bool                              `json:"debugLSP,omitempty"`
 85	ContextPaths []string                          `json:"contextPaths,omitempty"`
 86	TUI          TUIConfig                         `json:"tui"`
 87}
 88
 89// Application constants
 90const (
 91	defaultDataDirectory = ".opencode"
 92	defaultLogLevel      = "info"
 93	appName              = "opencode"
 94
 95	MaxTokensFallbackDefault = 4096
 96)
 97
 98var defaultContextPaths = []string{
 99	".github/copilot-instructions.md",
100	".cursorrules",
101	".cursor/rules/",
102	"CLAUDE.md",
103	"CLAUDE.local.md",
104	"opencode.md",
105	"opencode.local.md",
106	"OpenCode.md",
107	"OpenCode.local.md",
108	"OPENCODE.md",
109	"OPENCODE.local.md",
110}
111
112// Global configuration instance
113var cfg *Config
114
115// Load initializes the configuration from environment variables and config files.
116// If debug is true, debug mode is enabled and log level is set to debug.
117// It returns an error if configuration loading fails.
118func Load(workingDir string, debug bool) (*Config, error) {
119	if cfg != nil {
120		return cfg, nil
121	}
122
123	cfg = &Config{
124		WorkingDir: workingDir,
125		MCPServers: make(map[string]MCPServer),
126		Providers:  make(map[models.ModelProvider]Provider),
127		LSP:        make(map[string]LSPConfig),
128	}
129
130	configureViper()
131	setDefaults(debug)
132
133	// Read global config
134	if err := readConfig(viper.ReadInConfig()); err != nil {
135		return cfg, err
136	}
137
138	// Load and merge local config
139	mergeLocalConfig(workingDir)
140
141	setProviderDefaults()
142
143	// Apply configuration to the struct
144	if err := viper.Unmarshal(cfg); err != nil {
145		return cfg, fmt.Errorf("failed to unmarshal config: %w", err)
146	}
147
148	applyDefaultValues()
149	defaultLevel := slog.LevelInfo
150	if cfg.Debug {
151		defaultLevel = slog.LevelDebug
152	}
153	if os.Getenv("OPENCODE_DEV_DEBUG") == "true" {
154		loggingFile := fmt.Sprintf("%s/%s", cfg.Data.Directory, "debug.log")
155
156		// if file does not exist create it
157		if _, err := os.Stat(loggingFile); os.IsNotExist(err) {
158			if err := os.MkdirAll(cfg.Data.Directory, 0o755); err != nil {
159				return cfg, fmt.Errorf("failed to create directory: %w", err)
160			}
161			if _, err := os.Create(loggingFile); err != nil {
162				return cfg, fmt.Errorf("failed to create log file: %w", err)
163			}
164		}
165
166		sloggingFileWriter, err := os.OpenFile(loggingFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o666)
167		if err != nil {
168			return cfg, fmt.Errorf("failed to open log file: %w", err)
169		}
170		// Configure logger
171		logger := slog.New(slog.NewTextHandler(sloggingFileWriter, &slog.HandlerOptions{
172			Level: defaultLevel,
173		}))
174		slog.SetDefault(logger)
175	} else {
176		// Configure logger
177		logger := slog.New(slog.NewTextHandler(logging.NewWriter(), &slog.HandlerOptions{
178			Level: defaultLevel,
179		}))
180		slog.SetDefault(logger)
181	}
182
183	// Validate configuration
184	if err := Validate(); err != nil {
185		return cfg, fmt.Errorf("config validation failed: %w", err)
186	}
187
188	if cfg.Agents == nil {
189		cfg.Agents = make(map[AgentName]Agent)
190	}
191
192	// Override the max tokens for title agent
193	cfg.Agents[AgentTitle] = Agent{
194		Model:     cfg.Agents[AgentTitle].Model,
195		MaxTokens: 80,
196	}
197	return cfg, nil
198}
199
200// configureViper sets up viper's configuration paths and environment variables.
201func configureViper() {
202	viper.SetConfigName(fmt.Sprintf(".%s", appName))
203	viper.SetConfigType("json")
204	viper.AddConfigPath("$HOME")
205	viper.AddConfigPath(fmt.Sprintf("$XDG_CONFIG_HOME/%s", appName))
206	viper.AddConfigPath(fmt.Sprintf("$HOME/.config/%s", appName))
207	viper.SetEnvPrefix(strings.ToUpper(appName))
208	viper.AutomaticEnv()
209}
210
211// setDefaults configures default values for configuration options.
212func setDefaults(debug bool) {
213	viper.SetDefault("data.directory", defaultDataDirectory)
214	viper.SetDefault("contextPaths", defaultContextPaths)
215	viper.SetDefault("tui.theme", "opencode")
216
217	if debug {
218		viper.SetDefault("debug", true)
219		viper.Set("log.level", "debug")
220	} else {
221		viper.SetDefault("debug", false)
222		viper.SetDefault("log.level", defaultLogLevel)
223	}
224}
225
226// setProviderDefaults configures LLM provider defaults based on provider provided by
227// environment variables and configuration file.
228func setProviderDefaults() {
229	// Set all API keys we can find in the environment
230	if apiKey := os.Getenv("ANTHROPIC_API_KEY"); apiKey != "" {
231		viper.SetDefault("providers.anthropic.apiKey", apiKey)
232	}
233	if apiKey := os.Getenv("OPENAI_API_KEY"); apiKey != "" {
234		viper.SetDefault("providers.openai.apiKey", apiKey)
235	}
236	if apiKey := os.Getenv("GEMINI_API_KEY"); apiKey != "" {
237		viper.SetDefault("providers.gemini.apiKey", apiKey)
238	}
239	if apiKey := os.Getenv("GROQ_API_KEY"); apiKey != "" {
240		viper.SetDefault("providers.groq.apiKey", apiKey)
241	}
242	if apiKey := os.Getenv("OPENROUTER_API_KEY"); apiKey != "" {
243		viper.SetDefault("providers.openrouter.apiKey", apiKey)
244	}
245	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}