|
| 1 | +// Package config manages application configuration from various sources. |
1 | 2 | package config |
2 | 3 |
|
3 | 4 | import ( |
4 | 5 | "fmt" |
| 6 | + "log/slog" |
5 | 7 | "os" |
6 | 8 | "strings" |
7 | 9 |
|
8 | 10 | "github.com/kujtimiihoxha/termai/internal/llm/models" |
| 11 | + "github.com/kujtimiihoxha/termai/internal/logging" |
9 | 12 | "github.com/spf13/viper" |
10 | 13 | ) |
11 | 14 |
|
| 15 | +// MCPType defines the type of MCP (Model Control Protocol) server. |
12 | 16 | type MCPType string |
13 | 17 |
|
| 18 | +// Supported MCP types |
14 | 19 | const ( |
15 | 20 | MCPStdio MCPType = "stdio" |
16 | 21 | MCPSse MCPType = "sse" |
17 | 22 | ) |
18 | 23 |
|
| 24 | +// MCPServer defines the configuration for a Model Control Protocol server. |
19 | 25 | type MCPServer struct { |
20 | 26 | Command string `json:"command"` |
21 | 27 | Env []string `json:"env"` |
22 | 28 | Args []string `json:"args"` |
23 | 29 | Type MCPType `json:"type"` |
24 | 30 | URL string `json:"url"` |
25 | 31 | Headers map[string]string `json:"headers"` |
26 | | - // TODO: add permissions configuration |
27 | | - // TODO: add the ability to specify the tools to import |
28 | 32 | } |
29 | 33 |
|
| 34 | +// Model defines configuration for different LLM models and their token limits. |
30 | 35 | type Model struct { |
31 | 36 | Coder models.ModelID `json:"coder"` |
32 | 37 | CoderMaxTokens int64 `json:"coderMaxTokens"` |
33 | | - |
34 | | - Task models.ModelID `json:"task"` |
35 | | - TaskMaxTokens int64 `json:"taskMaxTokens"` |
36 | | - // TODO: Maybe support multiple models for different purposes |
37 | | -} |
38 | | - |
39 | | -type AnthropicConfig struct { |
40 | | - DisableCache bool `json:"disableCache"` |
41 | | - UseBedrock bool `json:"useBedrock"` |
| 38 | + Task models.ModelID `json:"task"` |
| 39 | + TaskMaxTokens int64 `json:"taskMaxTokens"` |
42 | 40 | } |
43 | 41 |
|
| 42 | +// Provider defines configuration for an LLM provider. |
44 | 43 | type Provider struct { |
45 | | - APIKey string `json:"apiKey"` |
46 | | - Enabled bool `json:"enabled"` |
| 44 | + APIKey string `json:"apiKey"` |
| 45 | + Disabled bool `json:"disabled"` |
47 | 46 | } |
48 | 47 |
|
| 48 | +// Data defines storage configuration. |
49 | 49 | type Data struct { |
50 | 50 | Directory string `json:"directory"` |
51 | 51 | } |
52 | 52 |
|
53 | | -type Log struct { |
54 | | - Level string `json:"level"` |
55 | | -} |
56 | | - |
| 53 | +// LSPConfig defines configuration for Language Server Protocol integration. |
57 | 54 | type LSPConfig struct { |
58 | 55 | Disabled bool `json:"enabled"` |
59 | 56 | Command string `json:"command"` |
60 | 57 | Args []string `json:"args"` |
61 | 58 | Options any `json:"options"` |
62 | 59 | } |
63 | 60 |
|
| 61 | +// Config is the main configuration structure for the application. |
64 | 62 | type Config struct { |
65 | | - Data *Data `json:"data,omitempty"` |
66 | | - Log *Log `json:"log,omitempty"` |
| 63 | + Data Data `json:"data"` |
| 64 | + WorkingDir string `json:"wd,omitempty"` |
67 | 65 | MCPServers map[string]MCPServer `json:"mcpServers,omitempty"` |
68 | 66 | Providers map[models.ModelProvider]Provider `json:"providers,omitempty"` |
69 | | - |
70 | | - LSP map[string]LSPConfig `json:"lsp,omitempty"` |
71 | | - |
72 | | - Model *Model `json:"model,omitempty"` |
73 | | - |
74 | | - Debug bool `json:"debug,omitempty"` |
| 67 | + LSP map[string]LSPConfig `json:"lsp,omitempty"` |
| 68 | + Model Model `json:"model"` |
| 69 | + Debug bool `json:"debug,omitempty"` |
75 | 70 | } |
76 | 71 |
|
77 | | -var cfg *Config |
78 | | - |
| 72 | +// Application constants |
79 | 73 | const ( |
80 | | - defaultDataDirectory = ".termai" |
| 74 | + defaultDataDirectory = ".opencode" |
81 | 75 | defaultLogLevel = "info" |
82 | 76 | defaultMaxTokens = int64(5000) |
83 | | - termai = "termai" |
| 77 | + appName = "opencode" |
84 | 78 | ) |
85 | 79 |
|
86 | | -func Load(debug bool) error { |
| 80 | +// Global configuration instance |
| 81 | +var 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. |
| 86 | +func Load(workingDir string, debug bool) error { |
87 | 87 | if cfg != nil { |
88 | 88 | return nil |
89 | 89 | } |
90 | 90 |
|
91 | | - viper.SetConfigName(fmt.Sprintf(".%s", termai)) |
| 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 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 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 nil |
| 127 | +} |
| 128 | + |
| 129 | +// configureViper sets up viper's configuration paths and environment variables. |
| 130 | +func configureViper() { |
| 131 | + viper.SetConfigName(fmt.Sprintf(".%s", appName)) |
92 | 132 | viper.SetConfigType("json") |
93 | 133 | viper.AddConfigPath("$HOME") |
94 | | - viper.AddConfigPath(fmt.Sprintf("$XDG_CONFIG_HOME/%s", termai)) |
95 | | - viper.SetEnvPrefix(strings.ToUpper(termai)) |
| 134 | + viper.AddConfigPath(fmt.Sprintf("$XDG_CONFIG_HOME/%s", appName)) |
| 135 | + viper.SetEnvPrefix(strings.ToUpper(appName)) |
| 136 | + viper.AutomaticEnv() |
| 137 | +} |
96 | 138 |
|
97 | | - // Add defaults |
| 139 | +// setDefaults configures default values for configuration options. |
| 140 | +func setDefaults(debug bool) { |
98 | 141 | viper.SetDefault("data.directory", defaultDataDirectory) |
| 142 | + |
99 | 143 | if debug { |
100 | 144 | viper.SetDefault("debug", true) |
101 | 145 | viper.Set("log.level", "debug") |
102 | 146 | } else { |
103 | 147 | viper.SetDefault("debug", false) |
104 | 148 | viper.SetDefault("log.level", defaultLogLevel) |
105 | 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 |
| 158 | +func 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 | + } |
106 | 176 |
|
107 | | - defaultModelSet := false |
108 | | - if os.Getenv("ANTHROPIC_API_KEY") != "" { |
109 | | - viper.SetDefault("providers.anthropic.apiKey", os.Getenv("ANTHROPIC_API_KEY")) |
110 | | - viper.SetDefault("providers.anthropic.enabled", true) |
| 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) |
111 | 189 | viper.SetDefault("model.coder", models.Claude37Sonnet) |
| 190 | + viper.SetDefault("model.coderMaxTokens", defaultMaxTokens) |
112 | 191 | viper.SetDefault("model.task", models.Claude37Sonnet) |
113 | | - defaultModelSet = true |
114 | | - } |
115 | | - if os.Getenv("OPENAI_API_KEY") != "" { |
116 | | - viper.SetDefault("providers.openai.apiKey", os.Getenv("OPENAI_API_KEY")) |
117 | | - viper.SetDefault("providers.openai.enabled", true) |
118 | | - if !defaultModelSet { |
119 | | - viper.SetDefault("model.coder", models.GPT41) |
120 | | - viper.SetDefault("model.task", models.GPT41) |
121 | | - defaultModelSet = true |
122 | | - } |
| 192 | + viper.SetDefault("model.taskMaxTokens", defaultMaxTokens) |
123 | 193 | } |
124 | | - if os.Getenv("GEMINI_API_KEY") != "" { |
125 | | - viper.SetDefault("providers.gemini.apiKey", os.Getenv("GEMINI_API_KEY")) |
126 | | - viper.SetDefault("providers.gemini.enabled", true) |
127 | | - if !defaultModelSet { |
128 | | - viper.SetDefault("model.coder", models.GRMINI20Flash) |
129 | | - viper.SetDefault("model.task", models.GRMINI20Flash) |
130 | | - defaultModelSet = true |
131 | | - } |
| 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) |
132 | 200 | } |
133 | | - if os.Getenv("GROQ_API_KEY") != "" { |
134 | | - viper.SetDefault("providers.groq.apiKey", os.Getenv("GROQ_API_KEY")) |
135 | | - viper.SetDefault("providers.groq.enabled", true) |
136 | | - if !defaultModelSet { |
137 | | - viper.SetDefault("model.coder", models.QWENQwq) |
138 | | - viper.SetDefault("model.task", models.QWENQwq) |
139 | | - defaultModelSet = true |
140 | | - } |
| 201 | +} |
| 202 | + |
| 203 | +// hasAWSCredentials checks if AWS credentials are available in the environment. |
| 204 | +func 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 |
141 | 208 | } |
142 | 209 |
|
143 | | - viper.SetDefault("providers.bedrock.enabled", true) |
144 | | - // TODO: add more providers |
145 | | - cfg = &Config{} |
| 210 | + // Check for AWS profile |
| 211 | + if os.Getenv("AWS_PROFILE") != "" || os.Getenv("AWS_DEFAULT_PROFILE") != "" { |
| 212 | + return true |
| 213 | + } |
146 | 214 |
|
147 | | - err := viper.ReadInConfig() |
148 | | - if err != nil { |
149 | | - if _, ok := err.(viper.ConfigFileNotFoundError); !ok { |
150 | | - return err |
151 | | - } |
| 215 | + // Check for AWS region |
| 216 | + if os.Getenv("AWS_REGION") != "" || os.Getenv("AWS_DEFAULT_REGION") != "" { |
| 217 | + return true |
152 | 218 | } |
153 | | - local := viper.New() |
154 | | - local.SetConfigName(fmt.Sprintf(".%s", termai)) |
155 | | - local.SetConfigType("json") |
156 | | - local.AddConfigPath(".") |
157 | | - // load local config, this will override the global config |
158 | | - if err = local.ReadInConfig(); err == nil { |
159 | | - viper.MergeConfigMap(local.AllSettings()) |
| 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. |
| 230 | +func readConfig(err error) error { |
| 231 | + if err == nil { |
| 232 | + return nil |
160 | 233 | } |
161 | | - viper.Unmarshal(cfg) |
162 | 234 |
|
163 | | - if cfg.Model != nil && cfg.Model.CoderMaxTokens <= 0 { |
164 | | - cfg.Model.CoderMaxTokens = defaultMaxTokens |
| 235 | + // It's okay if the config file doesn't exist |
| 236 | + if _, ok := err.(viper.ConfigFileNotFoundError); ok { |
| 237 | + return nil |
165 | 238 | } |
166 | | - if cfg.Model != nil && cfg.Model.TaskMaxTokens <= 0 { |
167 | | - cfg.Model.TaskMaxTokens = defaultMaxTokens |
| 239 | + |
| 240 | + return err |
| 241 | +} |
| 242 | + |
| 243 | +// mergeLocalConfig loads and merges configuration from the local directory. |
| 244 | +func 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()) |
168 | 253 | } |
| 254 | +} |
169 | 255 |
|
170 | | - for _, v := range cfg.MCPServers { |
| 256 | +// applyDefaultValues sets default values for configuration fields that need processing. |
| 257 | +func applyDefaultValues() { |
| 258 | + // Set default MCP type if not specified |
| 259 | + for k, v := range cfg.MCPServers { |
171 | 260 | if v.Type == "" { |
172 | 261 | v.Type = MCPStdio |
| 262 | + cfg.MCPServers[k] = v |
173 | 263 | } |
174 | 264 | } |
| 265 | +} |
175 | 266 |
|
| 267 | +// setWorkingDirectory stores the current working directory in the configuration. |
| 268 | +func setWorkingDirectory() { |
176 | 269 | workdir, err := os.Getwd() |
177 | | - if err != nil { |
178 | | - return err |
| 270 | + if err == nil { |
| 271 | + viper.Set("wd", workdir) |
179 | 272 | } |
180 | | - viper.Set("wd", workdir) |
181 | | - return nil |
182 | 273 | } |
183 | 274 |
|
| 275 | +// Get returns the current configuration. |
| 276 | +// It's safe to call this function multiple times. |
184 | 277 | func Get() *Config { |
185 | | - if cfg == nil { |
186 | | - err := Load(false) |
187 | | - if err != nil { |
188 | | - panic(err) |
189 | | - } |
190 | | - } |
191 | 278 | return cfg |
192 | 279 | } |
193 | 280 |
|
| 281 | +// WorkingDirectory returns the current working directory from the configuration. |
194 | 282 | func WorkingDirectory() string { |
195 | 283 | return viper.GetString("wd") |
196 | 284 | } |
197 | | - |
198 | | -func Write() error { |
199 | | - return viper.WriteConfig() |
200 | | -} |
|
0 commit comments