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/termai/internal/llm/models"
 11	"github.com/kujtimiihoxha/termai/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
 34// Model defines configuration for different LLM models and their token limits.
 35type Model struct {
 36	Coder          models.ModelID `json:"coder"`
 37	CoderMaxTokens int64          `json:"coderMaxTokens"`
 38	Task           models.ModelID `json:"task"`
 39	TaskMaxTokens  int64          `json:"taskMaxTokens"`
 40}
 41
 42// Provider defines configuration for an LLM provider.
 43type Provider struct {
 44	APIKey   string `json:"apiKey"`
 45	Disabled bool   `json:"disabled"`
 46}
 47
 48// Data defines storage configuration.
 49type Data struct {
 50	Directory string `json:"directory"`
 51}
 52
 53// LSPConfig defines configuration for Language Server Protocol integration.
 54type LSPConfig struct {
 55	Disabled bool     `json:"enabled"`
 56	Command  string   `json:"command"`
 57	Args     []string `json:"args"`
 58	Options  any      `json:"options"`
 59}
 60
 61// Config is the main configuration structure for the application.
 62type Config struct {
 63	Data       Data                              `json:"data"`
 64	WorkingDir string                            `json:"wd,omitempty"`
 65	MCPServers map[string]MCPServer              `json:"mcpServers,omitempty"`
 66	Providers  map[models.ModelProvider]Provider `json:"providers,omitempty"`
 67	LSP        map[string]LSPConfig              `json:"lsp,omitempty"`
 68	Model      Model                             `json:"model"`
 69	Debug      bool                              `json:"debug,omitempty"`
 70}
 71
 72// Application constants
 73const (
 74	defaultDataDirectory = ".opencode"
 75	defaultLogLevel      = "info"
 76	defaultMaxTokens     = int64(5000)
 77	appName              = "opencode"
 78)
 79
 80// Global configuration instance
 81var cfg *Config
 82
 83// Load initializes the configuration from environment variables and config files.
 84// If debug is true, debug mode is enabled and log level is set to debug.
 85// It returns an error if configuration loading fails.
 86func Load(workingDir string, debug bool) (*Config, error) {
 87	if cfg != nil {
 88		return cfg, nil
 89	}
 90
 91	cfg = &Config{
 92		WorkingDir: workingDir,
 93		MCPServers: make(map[string]MCPServer),
 94		Providers:  make(map[models.ModelProvider]Provider),
 95		LSP:        make(map[string]LSPConfig),
 96	}
 97
 98	configureViper()
 99	setDefaults(debug)
100	setProviderDefaults()
101
102	// Read global config
103	if err := readConfig(viper.ReadInConfig()); err != nil {
104		return cfg, err
105	}
106
107	// Load and merge local config
108	mergeLocalConfig(workingDir)
109
110	// Apply configuration to the struct
111	if err := viper.Unmarshal(cfg); err != nil {
112		return cfg, fmt.Errorf("failed to unmarshal config: %w", err)
113	}
114
115	applyDefaultValues()
116
117	defaultLevel := slog.LevelInfo
118	if cfg.Debug {
119		defaultLevel = slog.LevelDebug
120	}
121	// Configure logger
122	logger := slog.New(slog.NewTextHandler(logging.NewWriter(), &slog.HandlerOptions{
123		Level: defaultLevel,
124	}))
125	slog.SetDefault(logger)
126	return cfg, nil
127}
128
129// configureViper sets up viper's configuration paths and environment variables.
130func configureViper() {
131	viper.SetConfigName(fmt.Sprintf(".%s", appName))
132	viper.SetConfigType("json")
133	viper.AddConfigPath("$HOME")
134	viper.AddConfigPath(fmt.Sprintf("$XDG_CONFIG_HOME/%s", appName))
135	viper.SetEnvPrefix(strings.ToUpper(appName))
136	viper.AutomaticEnv()
137}
138
139// setDefaults configures default values for configuration options.
140func setDefaults(debug bool) {
141	viper.SetDefault("data.directory", defaultDataDirectory)
142
143	if debug {
144		viper.SetDefault("debug", true)
145		viper.Set("log.level", "debug")
146	} else {
147		viper.SetDefault("debug", false)
148		viper.SetDefault("log.level", defaultLogLevel)
149	}
150}
151
152// setProviderDefaults configures LLM provider defaults based on environment variables.
153// the default model priority is:
154// 1. Anthropic
155// 2. OpenAI
156// 3. Google Gemini
157// 4. AWS Bedrock
158func setProviderDefaults() {
159	// Groq configuration
160	if apiKey := os.Getenv("GROQ_API_KEY"); apiKey != "" {
161		viper.SetDefault("providers.groq.apiKey", apiKey)
162		viper.SetDefault("model.coder", models.QWENQwq)
163		viper.SetDefault("model.coderMaxTokens", defaultMaxTokens)
164		viper.SetDefault("model.task", models.QWENQwq)
165		viper.SetDefault("model.taskMaxTokens", defaultMaxTokens)
166	}
167
168	// Google Gemini configuration
169	if apiKey := os.Getenv("GEMINI_API_KEY"); apiKey != "" {
170		viper.SetDefault("providers.gemini.apiKey", apiKey)
171		viper.SetDefault("model.coder", models.GRMINI20Flash)
172		viper.SetDefault("model.coderMaxTokens", defaultMaxTokens)
173		viper.SetDefault("model.task", models.GRMINI20Flash)
174		viper.SetDefault("model.taskMaxTokens", defaultMaxTokens)
175	}
176
177	// OpenAI configuration
178	if apiKey := os.Getenv("OPENAI_API_KEY"); apiKey != "" {
179		viper.SetDefault("providers.openai.apiKey", apiKey)
180		viper.SetDefault("model.coder", models.GPT4o)
181		viper.SetDefault("model.coderMaxTokens", defaultMaxTokens)
182		viper.SetDefault("model.task", models.GPT4o)
183		viper.SetDefault("model.taskMaxTokens", defaultMaxTokens)
184	}
185
186	// Anthropic configuration
187	if apiKey := os.Getenv("ANTHROPIC_API_KEY"); apiKey != "" {
188		viper.SetDefault("providers.anthropic.apiKey", apiKey)
189		viper.SetDefault("model.coder", models.Claude37Sonnet)
190		viper.SetDefault("model.coderMaxTokens", defaultMaxTokens)
191		viper.SetDefault("model.task", models.Claude37Sonnet)
192		viper.SetDefault("model.taskMaxTokens", defaultMaxTokens)
193	}
194
195	if hasAWSCredentials() {
196		viper.SetDefault("model.coder", models.BedrockClaude37Sonnet)
197		viper.SetDefault("model.coderMaxTokens", defaultMaxTokens)
198		viper.SetDefault("model.task", models.BedrockClaude37Sonnet)
199		viper.SetDefault("model.taskMaxTokens", defaultMaxTokens)
200	}
201}
202
203// hasAWSCredentials checks if AWS credentials are available in the environment.
204func hasAWSCredentials() bool {
205	// Check for explicit AWS credentials
206	if os.Getenv("AWS_ACCESS_KEY_ID") != "" && os.Getenv("AWS_SECRET_ACCESS_KEY") != "" {
207		return true
208	}
209
210	// Check for AWS profile
211	if os.Getenv("AWS_PROFILE") != "" || os.Getenv("AWS_DEFAULT_PROFILE") != "" {
212		return true
213	}
214
215	// Check for AWS region
216	if os.Getenv("AWS_REGION") != "" || os.Getenv("AWS_DEFAULT_REGION") != "" {
217		return true
218	}
219
220	// Check if running on EC2 with instance profile
221	if os.Getenv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI") != "" ||
222		os.Getenv("AWS_CONTAINER_CREDENTIALS_FULL_URI") != "" {
223		return true
224	}
225
226	return false
227}
228
229// readConfig handles the result of reading a configuration file.
230func readConfig(err error) error {
231	if err == nil {
232		return nil
233	}
234
235	// It's okay if the config file doesn't exist
236	if _, ok := err.(viper.ConfigFileNotFoundError); ok {
237		return nil
238	}
239
240	return fmt.Errorf("failed to read config: %w", err)
241}
242
243// mergeLocalConfig loads and merges configuration from the local directory.
244func mergeLocalConfig(workingDir string) {
245	local := viper.New()
246	local.SetConfigName(fmt.Sprintf(".%s", appName))
247	local.SetConfigType("json")
248	local.AddConfigPath(workingDir)
249
250	// Merge local config if it exists
251	if err := local.ReadInConfig(); err == nil {
252		viper.MergeConfigMap(local.AllSettings())
253	}
254}
255
256// applyDefaultValues sets default values for configuration fields that need processing.
257func applyDefaultValues() {
258	// Set default MCP type if not specified
259	for k, v := range cfg.MCPServers {
260		if v.Type == "" {
261			v.Type = MCPStdio
262			cfg.MCPServers[k] = v
263		}
264	}
265}
266
267// Get returns the current configuration.
268// It's safe to call this function multiple times.
269func Get() *Config {
270	return cfg
271}
272
273// WorkingDirectory returns the current working directory from the configuration.
274func WorkingDirectory() string {
275	return viper.GetString("wd")
276}