config.go

  1// Package config manages application configuration from various sources.
  2package config
  3
  4import (
  5	"fmt"
  6	"log/slog"
  7	"os"
  8	"strings"
  9
 10	"github.com/kujtimiihoxha/opencode/internal/llm/models"
 11	"github.com/kujtimiihoxha/opencode/internal/logging"
 12	"github.com/spf13/viper"
 13)
 14
 15// MCPType defines the type of MCP (Model Control Protocol) server.
 16type MCPType string
 17
 18// Supported MCP types
 19const (
 20	MCPStdio MCPType = "stdio"
 21	MCPSse   MCPType = "sse"
 22)
 23
 24// MCPServer defines the configuration for a Model Control Protocol server.
 25type MCPServer struct {
 26	Command string            `json:"command"`
 27	Env     []string          `json:"env"`
 28	Args    []string          `json:"args"`
 29	Type    MCPType           `json:"type"`
 30	URL     string            `json:"url"`
 31	Headers map[string]string `json:"headers"`
 32}
 33
 34type AgentName string
 35
 36const (
 37	AgentCoder AgentName = "coder"
 38	AgentTask  AgentName = "task"
 39	AgentTitle AgentName = "title"
 40)
 41
 42// Agent defines configuration for different LLM models and their token limits.
 43type Agent struct {
 44	Model     models.ModelID `json:"model"`
 45	MaxTokens int64          `json:"maxTokens"`
 46}
 47
 48// Provider defines configuration for an LLM provider.
 49type Provider struct {
 50	APIKey   string `json:"apiKey"`
 51	Disabled bool   `json:"disabled"`
 52}
 53
 54// Data defines storage configuration.
 55type Data struct {
 56	Directory string `json:"directory"`
 57}
 58
 59// LSPConfig defines configuration for Language Server Protocol integration.
 60type LSPConfig struct {
 61	Disabled bool     `json:"enabled"`
 62	Command  string   `json:"command"`
 63	Args     []string `json:"args"`
 64	Options  any      `json:"options"`
 65}
 66
 67// Config is the main configuration structure for the application.
 68type Config struct {
 69	Data       Data                              `json:"data"`
 70	WorkingDir string                            `json:"wd,omitempty"`
 71	MCPServers map[string]MCPServer              `json:"mcpServers,omitempty"`
 72	Providers  map[models.ModelProvider]Provider `json:"providers,omitempty"`
 73	LSP        map[string]LSPConfig              `json:"lsp,omitempty"`
 74	Agents     map[AgentName]Agent               `json:"agents"`
 75	Debug      bool                              `json:"debug,omitempty"`
 76	DebugLSP   bool                              `json:"debugLSP,omitempty"`
 77}
 78
 79// Application constants
 80const (
 81	defaultDataDirectory = ".opencode"
 82	defaultLogLevel      = "info"
 83	defaultMaxTokens     = int64(5000)
 84	appName              = "opencode"
 85)
 86
 87// Global configuration instance
 88var cfg *Config
 89
 90// Load initializes the configuration from environment variables and config files.
 91// If debug is true, debug mode is enabled and log level is set to debug.
 92// It returns an error if configuration loading fails.
 93func Load(workingDir string, debug bool) (*Config, error) {
 94	if cfg != nil {
 95		return cfg, nil
 96	}
 97
 98	cfg = &Config{
 99		WorkingDir: workingDir,
100		MCPServers: make(map[string]MCPServer),
101		Providers:  make(map[models.ModelProvider]Provider),
102		LSP:        make(map[string]LSPConfig),
103	}
104
105	configureViper()
106	setDefaults(debug)
107	setProviderDefaults()
108
109	// Read global config
110	if err := readConfig(viper.ReadInConfig()); err != nil {
111		return cfg, err
112	}
113
114	// Load and merge local config
115	mergeLocalConfig(workingDir)
116
117	// Apply configuration to the struct
118	if err := viper.Unmarshal(cfg); err != nil {
119		return cfg, fmt.Errorf("failed to unmarshal config: %w", err)
120	}
121
122	applyDefaultValues()
123
124	defaultLevel := slog.LevelInfo
125	if cfg.Debug {
126		defaultLevel = slog.LevelDebug
127	}
128	// if we are in debug mode make the writer a file
129	if cfg.Debug {
130		loggingFile := fmt.Sprintf("%s/%s", cfg.Data.Directory, "debug.log")
131
132		// if file does not exist create it
133		if _, err := os.Stat(loggingFile); os.IsNotExist(err) {
134			if err := os.MkdirAll(cfg.Data.Directory, 0o755); err != nil {
135				return cfg, fmt.Errorf("failed to create directory: %w", err)
136			}
137			if _, err := os.Create(loggingFile); err != nil {
138				return cfg, fmt.Errorf("failed to create log file: %w", err)
139			}
140		}
141
142		sloggingFileWriter, err := os.OpenFile(loggingFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o666)
143		if err != nil {
144			return cfg, fmt.Errorf("failed to open log file: %w", err)
145		}
146		// Configure logger
147		logger := slog.New(slog.NewTextHandler(sloggingFileWriter, &slog.HandlerOptions{
148			Level: defaultLevel,
149		}))
150		slog.SetDefault(logger)
151	} else {
152		// Configure logger
153		logger := slog.New(slog.NewTextHandler(logging.NewWriter(), &slog.HandlerOptions{
154			Level: defaultLevel,
155		}))
156		slog.SetDefault(logger)
157	}
158
159	// Override the max tokens for title agent
160	cfg.Agents[AgentTitle] = Agent{
161		Model:     cfg.Agents[AgentTitle].Model,
162		MaxTokens: 80,
163	}
164	return cfg, nil
165}
166
167// configureViper sets up viper's configuration paths and environment variables.
168func configureViper() {
169	viper.SetConfigName(fmt.Sprintf(".%s", appName))
170	viper.SetConfigType("json")
171	viper.AddConfigPath("$HOME")
172	viper.AddConfigPath(fmt.Sprintf("$XDG_CONFIG_HOME/%s", appName))
173	viper.SetEnvPrefix(strings.ToUpper(appName))
174	viper.AutomaticEnv()
175}
176
177// setDefaults configures default values for configuration options.
178func setDefaults(debug bool) {
179	viper.SetDefault("data.directory", defaultDataDirectory)
180
181	if debug {
182		viper.SetDefault("debug", true)
183		viper.Set("log.level", "debug")
184	} else {
185		viper.SetDefault("debug", false)
186		viper.SetDefault("log.level", defaultLogLevel)
187	}
188}
189
190// setProviderDefaults configures LLM provider defaults based on environment variables.
191// the default model priority is:
192// 1. Anthropic
193// 2. OpenAI
194// 3. Google Gemini
195// 4. AWS Bedrock
196func setProviderDefaults() {
197	// Groq configuration
198	if apiKey := os.Getenv("GROQ_API_KEY"); apiKey != "" {
199		viper.SetDefault("providers.groq.apiKey", apiKey)
200		viper.SetDefault("agents.coder.model", models.QWENQwq)
201		viper.SetDefault("agents.coder.maxTokens", defaultMaxTokens)
202		viper.SetDefault("agents.task.model", models.QWENQwq)
203		viper.SetDefault("agents.task.maxTokens", defaultMaxTokens)
204		viper.SetDefault("agents.title.model", models.QWENQwq)
205	}
206
207	// Google Gemini configuration
208	if apiKey := os.Getenv("GEMINI_API_KEY"); apiKey != "" {
209		viper.SetDefault("providers.gemini.apiKey", apiKey)
210		viper.SetDefault("agents.coder.model", models.GRMINI20Flash)
211		viper.SetDefault("agents.coder.maxTokens", defaultMaxTokens)
212		viper.SetDefault("agents.task.model", models.GRMINI20Flash)
213		viper.SetDefault("agents.task.maxTokens", defaultMaxTokens)
214		viper.SetDefault("agents.title.model", models.GRMINI20Flash)
215	}
216
217	// OpenAI configuration
218	if apiKey := os.Getenv("OPENAI_API_KEY"); apiKey != "" {
219		viper.SetDefault("providers.openai.apiKey", apiKey)
220		viper.SetDefault("agents.coder.model", models.GPT4o)
221		viper.SetDefault("agents.coder.maxTokens", defaultMaxTokens)
222		viper.SetDefault("agents.task.model", models.GPT4o)
223		viper.SetDefault("agents.task.maxTokens", defaultMaxTokens)
224		viper.SetDefault("agents.title.model", models.GPT4o)
225
226	}
227
228	// Anthropic configuration
229	if apiKey := os.Getenv("ANTHROPIC_API_KEY"); apiKey != "" {
230		viper.SetDefault("providers.anthropic.apiKey", apiKey)
231		viper.SetDefault("agents.coder.model", models.Claude37Sonnet)
232		viper.SetDefault("agents.coder.maxTokens", defaultMaxTokens)
233		viper.SetDefault("agents.task.model", models.Claude37Sonnet)
234		viper.SetDefault("agents.task.maxTokens", defaultMaxTokens)
235		viper.SetDefault("agents.title.model", models.Claude37Sonnet)
236	}
237
238	if hasAWSCredentials() {
239		viper.SetDefault("agents.coder.model", models.BedrockClaude37Sonnet)
240		viper.SetDefault("agents.coder.maxTokens", defaultMaxTokens)
241		viper.SetDefault("agents.task.model", models.BedrockClaude37Sonnet)
242		viper.SetDefault("agents.task.maxTokens", defaultMaxTokens)
243		viper.SetDefault("agents.title.model", models.BedrockClaude37Sonnet)
244	}
245}
246
247// hasAWSCredentials checks if AWS credentials are available in the environment.
248func hasAWSCredentials() bool {
249	// Check for explicit AWS credentials
250	if os.Getenv("AWS_ACCESS_KEY_ID") != "" && os.Getenv("AWS_SECRET_ACCESS_KEY") != "" {
251		return true
252	}
253
254	// Check for AWS profile
255	if os.Getenv("AWS_PROFILE") != "" || os.Getenv("AWS_DEFAULT_PROFILE") != "" {
256		return true
257	}
258
259	// Check for AWS region
260	if os.Getenv("AWS_REGION") != "" || os.Getenv("AWS_DEFAULT_REGION") != "" {
261		return true
262	}
263
264	// Check if running on EC2 with instance profile
265	if os.Getenv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI") != "" ||
266		os.Getenv("AWS_CONTAINER_CREDENTIALS_FULL_URI") != "" {
267		return true
268	}
269
270	return false
271}
272
273// readConfig handles the result of reading a configuration file.
274func readConfig(err error) error {
275	if err == nil {
276		return nil
277	}
278
279	// It's okay if the config file doesn't exist
280	if _, ok := err.(viper.ConfigFileNotFoundError); ok {
281		return nil
282	}
283
284	return fmt.Errorf("failed to read config: %w", err)
285}
286
287// mergeLocalConfig loads and merges configuration from the local directory.
288func mergeLocalConfig(workingDir string) {
289	local := viper.New()
290	local.SetConfigName(fmt.Sprintf(".%s", appName))
291	local.SetConfigType("json")
292	local.AddConfigPath(workingDir)
293
294	// Merge local config if it exists
295	if err := local.ReadInConfig(); err == nil {
296		viper.MergeConfigMap(local.AllSettings())
297	}
298}
299
300// applyDefaultValues sets default values for configuration fields that need processing.
301func applyDefaultValues() {
302	// Set default MCP type if not specified
303	for k, v := range cfg.MCPServers {
304		if v.Type == "" {
305			v.Type = MCPStdio
306			cfg.MCPServers[k] = v
307		}
308	}
309}
310
311// Get returns the current configuration.
312// It's safe to call this function multiple times.
313func Get() *Config {
314	return cfg
315}
316
317// WorkingDirectory returns the current working directory from the configuration.
318func WorkingDirectory() string {
319	if cfg == nil {
320		panic("config not loaded")
321	}
322	return cfg.WorkingDir
323}