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 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}