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	ReasoningEffort string         `json:"reasoningEffort"` // For openai models low,medium,heigh
 47}
 48
 49// Provider defines configuration for an LLM provider.
 50type Provider struct {
 51	APIKey   string `json:"apiKey"`
 52	Disabled bool   `json:"disabled"`
 53}
 54
 55// Data defines storage configuration.
 56type Data struct {
 57	Directory string `json:"directory"`
 58}
 59
 60// LSPConfig defines configuration for Language Server Protocol integration.
 61type LSPConfig struct {
 62	Disabled bool     `json:"enabled"`
 63	Command  string   `json:"command"`
 64	Args     []string `json:"args"`
 65	Options  any      `json:"options"`
 66}
 67
 68// Config is the main configuration structure for the application.
 69type Config struct {
 70	Data       Data                              `json:"data"`
 71	WorkingDir string                            `json:"wd,omitempty"`
 72	MCPServers map[string]MCPServer              `json:"mcpServers,omitempty"`
 73	Providers  map[models.ModelProvider]Provider `json:"providers,omitempty"`
 74	LSP        map[string]LSPConfig              `json:"lsp,omitempty"`
 75	Agents     map[AgentName]Agent               `json:"agents"`
 76	Debug      bool                              `json:"debug,omitempty"`
 77	DebugLSP   bool                              `json:"debugLSP,omitempty"`
 78}
 79
 80// Application constants
 81const (
 82	defaultDataDirectory = ".opencode"
 83	defaultLogLevel      = "info"
 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	if cfg.Agents == nil {
160		cfg.Agents = make(map[AgentName]Agent)
161	}
162
163	// Override the max tokens for title agent
164	cfg.Agents[AgentTitle] = Agent{
165		Model:     cfg.Agents[AgentTitle].Model,
166		MaxTokens: 80,
167	}
168	return cfg, nil
169}
170
171// configureViper sets up viper's configuration paths and environment variables.
172func configureViper() {
173	viper.SetConfigName(fmt.Sprintf(".%s", appName))
174	viper.SetConfigType("json")
175	viper.AddConfigPath("$HOME")
176	viper.AddConfigPath(fmt.Sprintf("$XDG_CONFIG_HOME/%s", appName))
177	viper.SetEnvPrefix(strings.ToUpper(appName))
178	viper.AutomaticEnv()
179}
180
181// setDefaults configures default values for configuration options.
182func setDefaults(debug bool) {
183	viper.SetDefault("data.directory", defaultDataDirectory)
184
185	if debug {
186		viper.SetDefault("debug", true)
187		viper.Set("log.level", "debug")
188	} else {
189		viper.SetDefault("debug", false)
190		viper.SetDefault("log.level", defaultLogLevel)
191	}
192}
193
194// setProviderDefaults configures LLM provider defaults based on environment variables.
195// the default model priority is:
196// 1. Anthropic
197// 2. OpenAI
198// 3. Google Gemini
199// 4. AWS Bedrock
200func setProviderDefaults() {
201	// Groq configuration
202	if apiKey := os.Getenv("GROQ_API_KEY"); apiKey != "" {
203		viper.SetDefault("providers.groq.apiKey", apiKey)
204		viper.SetDefault("agents.coder.model", models.QWENQwq)
205		viper.SetDefault("agents.task.model", models.QWENQwq)
206		viper.SetDefault("agents.title.model", models.QWENQwq)
207	}
208
209	// Google Gemini configuration
210	if apiKey := os.Getenv("GEMINI_API_KEY"); apiKey != "" {
211		viper.SetDefault("providers.gemini.apiKey", apiKey)
212		viper.SetDefault("agents.coder.model", models.GRMINI20Flash)
213		viper.SetDefault("agents.task.model", models.GRMINI20Flash)
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.task.model", models.GPT4o)
222		viper.SetDefault("agents.title.model", models.GPT4o)
223
224	}
225
226	// Anthropic configuration
227	if apiKey := os.Getenv("ANTHROPIC_API_KEY"); apiKey != "" {
228		viper.SetDefault("providers.anthropic.apiKey", apiKey)
229		viper.SetDefault("agents.coder.model", models.Claude37Sonnet)
230		viper.SetDefault("agents.task.model", models.Claude37Sonnet)
231		viper.SetDefault("agents.title.model", models.Claude37Sonnet)
232	}
233
234	if hasAWSCredentials() {
235		viper.SetDefault("agents.coder.model", models.BedrockClaude37Sonnet)
236		viper.SetDefault("agents.task.model", models.BedrockClaude37Sonnet)
237		viper.SetDefault("agents.title.model", models.BedrockClaude37Sonnet)
238	}
239}
240
241// hasAWSCredentials checks if AWS credentials are available in the environment.
242func hasAWSCredentials() bool {
243	// Check for explicit AWS credentials
244	if os.Getenv("AWS_ACCESS_KEY_ID") != "" && os.Getenv("AWS_SECRET_ACCESS_KEY") != "" {
245		return true
246	}
247
248	// Check for AWS profile
249	if os.Getenv("AWS_PROFILE") != "" || os.Getenv("AWS_DEFAULT_PROFILE") != "" {
250		return true
251	}
252
253	// Check for AWS region
254	if os.Getenv("AWS_REGION") != "" || os.Getenv("AWS_DEFAULT_REGION") != "" {
255		return true
256	}
257
258	// Check if running on EC2 with instance profile
259	if os.Getenv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI") != "" ||
260		os.Getenv("AWS_CONTAINER_CREDENTIALS_FULL_URI") != "" {
261		return true
262	}
263
264	return false
265}
266
267// readConfig handles the result of reading a configuration file.
268func readConfig(err error) error {
269	if err == nil {
270		return nil
271	}
272
273	// It's okay if the config file doesn't exist
274	if _, ok := err.(viper.ConfigFileNotFoundError); ok {
275		return nil
276	}
277
278	return fmt.Errorf("failed to read config: %w", err)
279}
280
281// mergeLocalConfig loads and merges configuration from the local directory.
282func mergeLocalConfig(workingDir string) {
283	local := viper.New()
284	local.SetConfigName(fmt.Sprintf(".%s", appName))
285	local.SetConfigType("json")
286	local.AddConfigPath(workingDir)
287
288	// Merge local config if it exists
289	if err := local.ReadInConfig(); err == nil {
290		viper.MergeConfigMap(local.AllSettings())
291	}
292}
293
294// applyDefaultValues sets default values for configuration fields that need processing.
295func applyDefaultValues() {
296	// Set default MCP type if not specified
297	for k, v := range cfg.MCPServers {
298		if v.Type == "" {
299			v.Type = MCPStdio
300			cfg.MCPServers[k] = v
301		}
302	}
303}
304
305// Get returns the current configuration.
306// It's safe to call this function multiple times.
307func Get() *Config {
308	return cfg
309}
310
311// WorkingDirectory returns the current working directory from the configuration.
312func WorkingDirectory() string {
313	if cfg == nil {
314		panic("config not loaded")
315	}
316	return cfg.WorkingDir
317}