on main help sign in
cleanup config
5601466Kujtim Hoxha
internal/config/config.go+183−99
@@ -1,21 +1,27 @@
1+// Package config manages application configuration from various sources.
1package config
2package config
2
3
3import (
4import (
4 "fmt"
5 "fmt"
6+ "log/slog"
5 "os"
7 "os"
6 "strings"
8 "strings"
7
9
8 "github.com/kujtimiihoxha/termai/internal/llm/models"
10 "github.com/kujtimiihoxha/termai/internal/llm/models"
11+ "github.com/kujtimiihoxha/termai/internal/logging"
9 "github.com/spf13/viper"
12 "github.com/spf13/viper"
10)
13)
11
14
15+// MCPType defines the type of MCP (Model Control Protocol) server.
12type MCPType string
16type MCPType string
13
17
18+// Supported MCP types
14const (
19const (
15 MCPStdio MCPType = "stdio"
20 MCPStdio MCPType = "stdio"
16 MCPSse MCPType = "sse"
21 MCPSse MCPType = "sse"
17)
22)
18
23
24+// MCPServer defines the configuration for a Model Control Protocol server.
19type MCPServer struct {
25type MCPServer struct {
20 Command string `json:"command"`
26 Command string `json:"command"`
21 Env []string `json:"env"`
27 Env []string `json:"env"`
@@ -23,37 +29,28 @@ type MCPServer struct {
23 Type MCPType `json:"type"`
29 Type MCPType `json:"type"`
24 URL string `json:"url"`
30 URL string `json:"url"`
25 Headers map[string]string `json:"headers"`
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.
30type Model struct {
35type Model struct {
31 Coder models.ModelID `json:"coder"`
36 Coder models.ModelID `json:"coder"`
32 CoderMaxTokens int64 `json:"coderMaxTokens"`
37 CoderMaxTokens int64 `json:"coderMaxTokens"`
33−
34− Task models.ModelID `json:"task"`
38+ Task models.ModelID `json:"task"`
35− TaskMaxTokens int64 `json:"taskMaxTokens"`
39+ 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"`
42}
40}
43
41
42+// Provider defines configuration for an LLM provider.
44type Provider struct {
43type Provider struct {
45− APIKey string `json:"apiKey"`
44+ APIKey string `json:"apiKey"`
46− Enabled bool `json:"enabled"`
45+ Disabled bool `json:"disabled"`
47}
46}
48
47
48+// Data defines storage configuration.
49type Data struct {
49type Data struct {
50 Directory string `json:"directory"`
50 Directory string `json:"directory"`
51}
51}
52
52
53−type Log struct {
53+// LSPConfig defines configuration for Language Server Protocol integration.
54− Level string `json:"level"`
55−}
56−
57type LSPConfig struct {
54type LSPConfig struct {
58 Disabled bool `json:"enabled"`
55 Disabled bool `json:"enabled"`
59 Command string `json:"command"`
56 Command string `json:"command"`
@@ -61,41 +58,88 @@ type LSPConfig struct {
61 Options any `json:"options"`
58 Options any `json:"options"`
62}
59}
63
60
61+// Config is the main configuration structure for the application.
64type Config struct {
62type Config struct {
65− Data *Data `json:"data,omitempty"`
63+ Data Data `json:"data"`
66− Log *Log `json:"log,omitempty"`
64+ WorkingDir string `json:"wd,omitempty"`
67 MCPServers map[string]MCPServer `json:"mcpServers,omitempty"`
65 MCPServers map[string]MCPServer `json:"mcpServers,omitempty"`
68 Providers map[models.ModelProvider]Provider `json:"providers,omitempty"`
66 Providers map[models.ModelProvider]Provider `json:"providers,omitempty"`
69−
70− LSP map[string]LSPConfig `json:"lsp,omitempty"`
67+ LSP map[string]LSPConfig `json:"lsp,omitempty"`
71−
72− Model *Model `json:"model,omitempty"`
68+ Model Model `json:"model"`
73−
74− Debug bool `json:"debug,omitempty"`
69+ Debug bool `json:"debug,omitempty"`
75}
70}
76
71
77−var cfg *Config
72+// Application constants
78−
79const (
73const (
80− defaultDataDirectory = ".termai"
74+ defaultDataDirectory = ".opencode"
81 defaultLogLevel = "info"
75 defaultLogLevel = "info"
82 defaultMaxTokens = int64(5000)
76 defaultMaxTokens = int64(5000)
83− termai = "termai"
77+ appName = "opencode"
84)
78)
85
79
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(debug bool) error {
86+func Load(workingDir string, debug bool) error {
87 if cfg != nil {
87 if cfg != nil {
88 return nil
88 return nil
89 }
89 }
90
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 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() {
91− viper.SetConfigName(fmt.Sprintf(".%s", termai))
131+ viper.SetConfigName(fmt.Sprintf(".%s", appName))
92 viper.SetConfigType("json")
132 viper.SetConfigType("json")
93 viper.AddConfigPath("$HOME")
133 viper.AddConfigPath("$HOME")
94− viper.AddConfigPath(fmt.Sprintf("$XDG_CONFIG_HOME/%s", termai))
134+ viper.AddConfigPath(fmt.Sprintf("$XDG_CONFIG_HOME/%s", appName))
95− viper.SetEnvPrefix(strings.ToUpper(termai))
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 viper.SetDefault("data.directory", defaultDataDirectory)
141 viper.SetDefault("data.directory", defaultDataDirectory)
142+
99 if debug {
143 if debug {
100 viper.SetDefault("debug", true)
144 viper.SetDefault("debug", true)
101 viper.Set("log.level", "debug")
145 viper.Set("log.level", "debug")
@@ -103,98 +147,138 @@ func Load(debug bool) error {
103 viper.SetDefault("debug", false)
147 viper.SetDefault("debug", false)
104 viper.SetDefault("log.level", defaultLogLevel)
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
177+ // OpenAI configuration
108− if os.Getenv("ANTHROPIC_API_KEY") != "" {
178+ if apiKey := os.Getenv("OPENAI_API_KEY"); apiKey != "" {
109− viper.SetDefault("providers.anthropic.apiKey", os.Getenv("ANTHROPIC_API_KEY"))
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 != "" {
110− viper.SetDefault("providers.anthropic.enabled", true)
188+ viper.SetDefault("providers.anthropic.apiKey", apiKey)
111 viper.SetDefault("model.coder", models.Claude37Sonnet)
189 viper.SetDefault("model.coder", models.Claude37Sonnet)
190+ viper.SetDefault("model.coderMaxTokens", defaultMaxTokens)
112 viper.SetDefault("model.task", models.Claude37Sonnet)
191 viper.SetDefault("model.task", models.Claude37Sonnet)
113− defaultModelSet = true
192+ viper.SetDefault("model.taskMaxTokens", defaultMaxTokens)
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− }
123 }
193 }
124− if os.Getenv("GEMINI_API_KEY") != "" {
194+
125− viper.SetDefault("providers.gemini.apiKey", os.Getenv("GEMINI_API_KEY"))
195+ if hasAWSCredentials() {
126− viper.SetDefault("providers.gemini.enabled", true)
127− if !defaultModelSet {
128− viper.SetDefault("model.coder", models.GRMINI20Flash)
196+ viper.SetDefault("model.coder", models.BedrockClaude37Sonnet)
197+ viper.SetDefault("model.coderMaxTokens", defaultMaxTokens)
129− viper.SetDefault("model.task", models.GRMINI20Flash)
198+ viper.SetDefault("model.task", models.BedrockClaude37Sonnet)
130− defaultModelSet = true
199+ viper.SetDefault("model.taskMaxTokens", defaultMaxTokens)
131− }
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)
210+ // Check for AWS profile
144− // TODO: add more providers
211+ if os.Getenv("AWS_PROFILE") != "" || os.Getenv("AWS_DEFAULT_PROFILE") != "" {
145− cfg = &Config{}
212+ return true
213+ }
146
214
147− err := viper.ReadInConfig()
215+ // Check for AWS region
148− if err != nil {
216+ if os.Getenv("AWS_REGION") != "" || os.Getenv("AWS_DEFAULT_REGION") != "" {
149− if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
150− return err
217+ return true
151− }
152 }
218 }
153− local := viper.New()
219+
154− local.SetConfigName(fmt.Sprintf(".%s", termai))
220+ // Check if running on EC2 with instance profile
155− local.SetConfigType("json")
221+ if os.Getenv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI") != "" ||
156− local.AddConfigPath(".")
222+ os.Getenv("AWS_CONTAINER_CREDENTIALS_FULL_URI") != "" {
157− // load local config, this will override the global config
223+ return true
158− if err = local.ReadInConfig(); err == nil {
224+ }
159− viper.MergeConfigMap(local.AllSettings())
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 {
235+ // It's okay if the config file doesn't exist
164− cfg.Model.CoderMaxTokens = defaultMaxTokens
236+ if _, ok := err.(viper.ConfigFileNotFoundError); ok {
237+ return nil
165 }
238 }
166− if cfg.Model != nil && cfg.Model.TaskMaxTokens <= 0 {
239+
167− cfg.Model.TaskMaxTokens = defaultMaxTokens
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
256+// applyDefaultValues sets default values for configuration fields that need processing.
257+func applyDefaultValues() {
258+ // Set default MCP type if not specified
170− for _, v := range cfg.MCPServers {
259+ for k, v := range cfg.MCPServers {
171 if v.Type == "" {
260 if v.Type == "" {
172 v.Type = MCPStdio
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 workdir, err := os.Getwd()
269 workdir, err := os.Getwd()
177− if err != nil {
270+ if err == nil {
178− return err
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.
184func Get() *Config {
277func Get() *Config {
185− if cfg == nil {
186− err := Load(false)
187− if err != nil {
188− panic(err)
189− }
190− }
191 return cfg
278 return cfg
192}
279}
193
280
281+// WorkingDirectory returns the current working directory from the configuration.
194func WorkingDirectory() string {
282func WorkingDirectory() string {
195 return viper.GetString("wd")
283 return viper.GetString("wd")
196}
284}
197−
198−func Write() error {
199− return viper.WriteConfig()
200−}
internal/config/config_test.godeleted−465
@@ -1,465 +0,0 @@
1−package config
2−
3−import (
4− "fmt"
5− "os"
6− "path/filepath"
7− "testing"
8−
9− "github.com/kujtimiihoxha/termai/internal/llm/models"
10− "github.com/spf13/viper"
11− "github.com/stretchr/testify/assert"
12− "github.com/stretchr/testify/require"
13−)
14−
15−func TestLoad(t *testing.T) {
16− setupTest(t)
17−
18− t.Run("loads configuration successfully", func(t *testing.T) {
19− homeDir := t.TempDir()
20− t.Setenv("HOME", homeDir)
21− configPath := filepath.Join(homeDir, ".termai.json")
22−
23− configContent := `{
24− "data": {
25− "directory": "custom-dir"
26− },
27− "log": {
28− "level": "debug"
29− },
30− "mcpServers": {
31− "test-server": {
32− "command": "test-command",
33− "env": ["TEST_ENV=value"],
34− "args": ["--arg1", "--arg2"],
35− "type": "stdio",
36− "url": "",
37− "headers": {}
38− },
39− "sse-server": {
40− "command": "",
41− "env": [],
42− "args": [],
43− "type": "sse",
44− "url": "https://api.example.com/events",
45− "headers": {
46− "Authorization": "Bearer token123",
47− "Content-Type": "application/json"
48− }
49− }
50− },
51− "providers": {
52− "anthropic": {
53− "apiKey": "test-api-key",
54− "enabled": true
55− }
56− },
57− "model": {
58− "coder": "claude-3-haiku",
59− "task": "claude-3-haiku"
60− }
61− }`
62− err := os.WriteFile(configPath, []byte(configContent), 0o644)
63− require.NoError(t, err)
64−
65− cfg = nil
66− viper.Reset()
67−
68− err = Load(false)
69− require.NoError(t, err)
70−
71− config := Get()
72− assert.NotNil(t, config)
73− assert.Equal(t, "custom-dir", config.Data.Directory)
74− assert.Equal(t, "debug", config.Log.Level)
75−
76− assert.Contains(t, config.MCPServers, "test-server")
77− stdioServer := config.MCPServers["test-server"]
78− assert.Equal(t, "test-command", stdioServer.Command)
79− assert.Equal(t, []string{"TEST_ENV=value"}, stdioServer.Env)
80− assert.Equal(t, []string{"--arg1", "--arg2"}, stdioServer.Args)
81− assert.Equal(t, MCPStdio, stdioServer.Type)
82− assert.Equal(t, "", stdioServer.URL)
83− assert.Empty(t, stdioServer.Headers)
84−
85− assert.Contains(t, config.MCPServers, "sse-server")
86− sseServer := config.MCPServers["sse-server"]
87− assert.Equal(t, "", sseServer.Command)
88− assert.Empty(t, sseServer.Env)
89− assert.Empty(t, sseServer.Args)
90− assert.Equal(t, MCPSse, sseServer.Type)
91− assert.Equal(t, "https://api.example.com/events", sseServer.URL)
92− assert.Equal(t, map[string]string{
93− "authorization": "Bearer token123",
94− "content-type": "application/json",
95− }, sseServer.Headers)
96−
97− assert.Contains(t, config.Providers, models.ModelProvider("anthropic"))
98− provider := config.Providers[models.ModelProvider("anthropic")]
99− assert.Equal(t, "test-api-key", provider.APIKey)
100− assert.True(t, provider.Enabled)
101−
102− assert.NotNil(t, config.Model)
103− assert.Equal(t, models.Claude3Haiku, config.Model.Coder)
104− assert.Equal(t, models.Claude3Haiku, config.Model.Task)
105− assert.Equal(t, defaultMaxTokens, config.Model.CoderMaxTokens)
106− })
107−
108− t.Run("loads configuration with environment variables", func(t *testing.T) {
109− homeDir := t.TempDir()
110− t.Setenv("HOME", homeDir)
111− configPath := filepath.Join(homeDir, ".termai.json")
112− err := os.WriteFile(configPath, []byte("{}"), 0o644)
113− require.NoError(t, err)
114−
115− t.Setenv("ANTHROPIC_API_KEY", "env-anthropic-key")
116− t.Setenv("OPENAI_API_KEY", "env-openai-key")
117− t.Setenv("GEMINI_API_KEY", "env-gemini-key")
118−
119− cfg = nil
120− viper.Reset()
121−
122− err = Load(false)
123− require.NoError(t, err)
124−
125− config := Get()
126− assert.NotNil(t, config)
127−
128− assert.Equal(t, defaultDataDirectory, config.Data.Directory)
129− assert.Equal(t, defaultLogLevel, config.Log.Level)
130−
131− assert.Contains(t, config.Providers, models.ModelProvider("anthropic"))
132− assert.Equal(t, "env-anthropic-key", config.Providers[models.ModelProvider("anthropic")].APIKey)
133− assert.True(t, config.Providers[models.ModelProvider("anthropic")].Enabled)
134−
135− assert.Contains(t, config.Providers, models.ModelProvider("openai"))
136− assert.Equal(t, "env-openai-key", config.Providers[models.ModelProvider("openai")].APIKey)
137− assert.True(t, config.Providers[models.ModelProvider("openai")].Enabled)
138−
139− assert.Contains(t, config.Providers, models.ModelProvider("gemini"))
140− assert.Equal(t, "env-gemini-key", config.Providers[models.ModelProvider("gemini")].APIKey)
141− assert.True(t, config.Providers[models.ModelProvider("gemini")].Enabled)
142−
143− assert.Equal(t, models.Claude37Sonnet, config.Model.Coder)
144− })
145−
146− t.Run("local config overrides global config", func(t *testing.T) {
147− homeDir := t.TempDir()
148− t.Setenv("HOME", homeDir)
149− globalConfigPath := filepath.Join(homeDir, ".termai.json")
150− globalConfig := `{
151− "data": {
152− "directory": "global-dir"
153− },
154− "log": {
155− "level": "info"
156− }
157− }`
158− err := os.WriteFile(globalConfigPath, []byte(globalConfig), 0o644)
159− require.NoError(t, err)
160−
161− workDir := t.TempDir()
162− origDir, err := os.Getwd()
163− require.NoError(t, err)
164− defer os.Chdir(origDir)
165− err = os.Chdir(workDir)
166− require.NoError(t, err)
167−
168− localConfigPath := filepath.Join(workDir, ".termai.json")
169− localConfig := `{
170− "data": {
171− "directory": "local-dir"
172− },
173− "log": {
174− "level": "debug"
175− }
176− }`
177− err = os.WriteFile(localConfigPath, []byte(localConfig), 0o644)
178− require.NoError(t, err)
179−
180− cfg = nil
181− viper.Reset()
182−
183− err = Load(false)
184− require.NoError(t, err)
185−
186− config := Get()
187− assert.NotNil(t, config)
188−
189− assert.Equal(t, "local-dir", config.Data.Directory)
190− assert.Equal(t, "debug", config.Log.Level)
191− })
192−
193− t.Run("missing config file should not return error", func(t *testing.T) {
194− emptyDir := t.TempDir()
195− t.Setenv("HOME", emptyDir)
196−
197− cfg = nil
198− viper.Reset()
199−
200− err := Load(false)
201− assert.NoError(t, err)
202− })
203−
204− t.Run("model priority and fallbacks", func(t *testing.T) {
205− testCases := []struct {
206− name string
207− anthropicKey string
208− openaiKey string
209− geminiKey string
210− expectedModel models.ModelID
211− explicitModel models.ModelID
212− useExplicitModel bool
213− }{
214− {
215− name: "anthropic has priority",
216− anthropicKey: "test-key",
217− openaiKey: "test-key",
218− geminiKey: "test-key",
219− expectedModel: models.Claude37Sonnet,
220− },
221− {
222− name: "fallback to openai when no anthropic",
223− anthropicKey: "",
224− openaiKey: "test-key",
225− geminiKey: "test-key",
226− expectedModel: models.GPT41,
227− },
228− {
229− name: "fallback to gemini when no others",
230− anthropicKey: "",
231− openaiKey: "",
232− geminiKey: "test-key",
233− expectedModel: models.GRMINI20Flash,
234− },
235− {
236− name: "explicit model overrides defaults",
237− anthropicKey: "test-key",
238− openaiKey: "test-key",
239− geminiKey: "test-key",
240− explicitModel: models.GPT41,
241− useExplicitModel: true,
242− expectedModel: models.GPT41,
243− },
244− }
245−
246− for _, tc := range testCases {
247− t.Run(tc.name, func(t *testing.T) {
248− homeDir := t.TempDir()
249− t.Setenv("HOME", homeDir)
250− configPath := filepath.Join(homeDir, ".termai.json")
251−
252− configContent := "{}"
253− if tc.useExplicitModel {
254− configContent = fmt.Sprintf(`{"model":{"coder":"%s"}}`, tc.explicitModel)
255− }
256−
257− err := os.WriteFile(configPath, []byte(configContent), 0o644)
258− require.NoError(t, err)
259−
260− if tc.anthropicKey != "" {
261− t.Setenv("ANTHROPIC_API_KEY", tc.anthropicKey)
262− } else {
263− t.Setenv("ANTHROPIC_API_KEY", "")
264− }
265−
266− if tc.openaiKey != "" {
267− t.Setenv("OPENAI_API_KEY", tc.openaiKey)
268− } else {
269− t.Setenv("OPENAI_API_KEY", "")
270− }
271−
272− if tc.geminiKey != "" {
273− t.Setenv("GEMINI_API_KEY", tc.geminiKey)
274− } else {
275− t.Setenv("GEMINI_API_KEY", "")
276− }
277−
278− cfg = nil
279− viper.Reset()
280−
281− err = Load(false)
282− require.NoError(t, err)
283−
284− config := Get()
285− assert.NotNil(t, config)
286− assert.Equal(t, tc.expectedModel, config.Model.Coder)
287− })
288− }
289− })
290−}
291−
292−func TestGet(t *testing.T) {
293− t.Run("get returns same config instance", func(t *testing.T) {
294− setupTest(t)
295− homeDir := t.TempDir()
296− t.Setenv("HOME", homeDir)
297− configPath := filepath.Join(homeDir, ".termai.json")
298− err := os.WriteFile(configPath, []byte("{}"), 0o644)
299− require.NoError(t, err)
300−
301− cfg = nil
302− viper.Reset()
303−
304− config1 := Get()
305− require.NotNil(t, config1)
306−
307− config2 := Get()
308− require.NotNil(t, config2)
309−
310− assert.Same(t, config1, config2)
311− })
312−
313− t.Run("get loads config if not loaded", func(t *testing.T) {
314− setupTest(t)
315− homeDir := t.TempDir()
316− t.Setenv("HOME", homeDir)
317− configPath := filepath.Join(homeDir, ".termai.json")
318− configContent := `{"data":{"directory":"test-dir"}}`
319− err := os.WriteFile(configPath, []byte(configContent), 0o644)
320− require.NoError(t, err)
321−
322− cfg = nil
323− viper.Reset()
324−
325− config := Get()
326− require.NotNil(t, config)
327− assert.Equal(t, "test-dir", config.Data.Directory)
328− })
329−}
330−
331−func TestWorkingDirectory(t *testing.T) {
332− t.Run("returns current working directory", func(t *testing.T) {
333− setupTest(t)
334− homeDir := t.TempDir()
335− t.Setenv("HOME", homeDir)
336− configPath := filepath.Join(homeDir, ".termai.json")
337− err := os.WriteFile(configPath, []byte("{}"), 0o644)
338− require.NoError(t, err)
339−
340− cfg = nil
341− viper.Reset()
342−
343− err = Load(false)
344− require.NoError(t, err)
345−
346− wd := WorkingDirectory()
347− expectedWd, err := os.Getwd()
348− require.NoError(t, err)
349− assert.Equal(t, expectedWd, wd)
350− })
351−}
352−
353−func TestWrite(t *testing.T) {
354− t.Run("writes config to file", func(t *testing.T) {
355− setupTest(t)
356− homeDir := t.TempDir()
357− t.Setenv("HOME", homeDir)
358− configPath := filepath.Join(homeDir, ".termai.json")
359− err := os.WriteFile(configPath, []byte("{}"), 0o644)
360− require.NoError(t, err)
361−
362− cfg = nil
363− viper.Reset()
364−
365− err = Load(false)
366− require.NoError(t, err)
367−
368− viper.Set("data.directory", "modified-dir")
369−
370− err = Write()
371− require.NoError(t, err)
372−
373− content, err := os.ReadFile(configPath)
374− require.NoError(t, err)
375− assert.Contains(t, string(content), "modified-dir")
376− })
377−}
378−
379−func TestMCPType(t *testing.T) {
380− t.Run("MCPType constants", func(t *testing.T) {
381− assert.Equal(t, MCPType("stdio"), MCPStdio)
382− assert.Equal(t, MCPType("sse"), MCPSse)
383− })
384−
385− t.Run("MCPType JSON unmarshaling", func(t *testing.T) {
386− homeDir := t.TempDir()
387− t.Setenv("HOME", homeDir)
388− configPath := filepath.Join(homeDir, ".termai.json")
389−
390− configContent := `{
391− "mcpServers": {
392− "stdio-server": {
393− "type": "stdio"
394− },
395− "sse-server": {
396− "type": "sse"
397− },
398− "invalid-server": {
399− "type": "invalid"
400− }
401− }
402− }`
403− err := os.WriteFile(configPath, []byte(configContent), 0o644)
404− require.NoError(t, err)
405−
406− cfg = nil
407− viper.Reset()
408−
409− err = Load(false)
410− require.NoError(t, err)
411−
412− config := Get()
413− assert.NotNil(t, config)
414−
415− assert.Equal(t, MCPStdio, config.MCPServers["stdio-server"].Type)
416− assert.Equal(t, MCPSse, config.MCPServers["sse-server"].Type)
417− assert.Equal(t, MCPType("invalid"), config.MCPServers["invalid-server"].Type)
418− })
419−
420− t.Run("default MCPType", func(t *testing.T) {
421− homeDir := t.TempDir()
422− t.Setenv("HOME", homeDir)
423− configPath := filepath.Join(homeDir, ".termai.json")
424−
425− configContent := `{
426− "mcpServers": {
427− "test-server": {
428− "command": "test-command"
429− }
430− }
431− }`
432− err := os.WriteFile(configPath, []byte(configContent), 0o644)
433− require.NoError(t, err)
434−
435− cfg = nil
436− viper.Reset()
437−
438− err = Load(false)
439− require.NoError(t, err)
440−
441− config := Get()
442− assert.NotNil(t, config)
443−
444− assert.Equal(t, MCPType(""), config.MCPServers["test-server"].Type)
445− })
446−}
447−
448−func setupTest(t *testing.T) {
449− origHome := os.Getenv("HOME")
450− origXdgConfigHome := os.Getenv("XDG_CONFIG_HOME")
451− origAnthropicKey := os.Getenv("ANTHROPIC_API_KEY")
452− origOpenAIKey := os.Getenv("OPENAI_API_KEY")
453− origGeminiKey := os.Getenv("GEMINI_API_KEY")
454−
455− t.Cleanup(func() {
456− t.Setenv("HOME", origHome)
457− t.Setenv("XDG_CONFIG_HOME", origXdgConfigHome)
458− t.Setenv("ANTHROPIC_API_KEY", origAnthropicKey)
459− t.Setenv("OPENAI_API_KEY", origOpenAIKey)
460− t.Setenv("GEMINI_API_KEY", origGeminiKey)
461−
462− cfg = nil
463− viper.Reset()
464− })
465−}