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