on main help sign in
chore: initial config tests
d4927acKujtim Hoxha
cspell.json+1−1
@@ -1 +1 @@
1−{"flagWords":[],"words":["afero","alecthomas","bubbletea","charmbracelet","charmtone","Charple","crush","diffview","Emph","filepicker","Focusable","fsext","GROQ","Guac","imageorient","Lanczos","lipgloss","lsps","lucasb","nfnt","oksvg","Preproc","rasterx","rivo","Sourcegraph","srwiley","Strikethrough","termenv","textinput","trashhalo","uniseg","Unticked","genai","jsonschema"],"version":"0.2","language":"en"}
1+{"language":"en","version":"0.2","flagWords":[],"words":["afero","alecthomas","bubbletea","charmbracelet","charmtone","Charple","crush","diffview","Emph","filepicker","Focusable","fsext","GROQ","Guac","imageorient","Lanczos","lipgloss","lsps","lucasb","nfnt","oksvg","Preproc","rasterx","rivo","Sourcegraph","srwiley","Strikethrough","termenv","textinput","trashhalo","uniseg","Unticked","genai","jsonschema","preconfigured","jsons","qjebbs","LOCALAPPDATA","USERPROFILE","stretchr"]}
go.mod+5−1
@@ -40,7 +40,11 @@ require (
40 mvdan.cc/sh/v3 v3.11.0
40 mvdan.cc/sh/v3 v3.11.0
41)
41)
42
42
43+require (
44+ github.com/joho/godotenv v1.5.1 // indirect
45+ github.com/qjebbs/go-jsons v0.0.0-20221222033332-a534c5fc1c4c // indirect
43−require github.com/spf13/cast v1.7.1 // indirect
46+ github.com/spf13/cast v1.7.1 // indirect
47+)
44
48
45require (
49require (
46 cloud.google.com/go v0.116.0 // indirect
50 cloud.google.com/go v0.116.0 // indirect
go.sum+4
@@ -155,6 +155,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
155github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
155github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
156github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
156github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
157github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
157github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
158+github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
159+github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
158github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
160github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
159github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
161github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
160github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
162github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -208,6 +210,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
208github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
210github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
209github.com/pressly/goose/v3 v3.24.2 h1:c/ie0Gm8rnIVKvnDQ/scHErv46jrDv9b4I0WRcFJzYU=
211github.com/pressly/goose/v3 v3.24.2 h1:c/ie0Gm8rnIVKvnDQ/scHErv46jrDv9b4I0WRcFJzYU=
210github.com/pressly/goose/v3 v3.24.2/go.mod h1:kjefwFB0eR4w30Td2Gj2Mznyw94vSP+2jJYkOVNbD1k=
212github.com/pressly/goose/v3 v3.24.2/go.mod h1:kjefwFB0eR4w30Td2Gj2Mznyw94vSP+2jJYkOVNbD1k=
213+github.com/qjebbs/go-jsons v0.0.0-20221222033332-a534c5fc1c4c h1:kmzxiX+OB0knCo1V0dkEkdPelzCdAzCURCfmFArn2/A=
214+github.com/qjebbs/go-jsons v0.0.0-20221222033332-a534c5fc1c4c/go.mod h1:wNJrtinHyC3YSf6giEh4FJN8+yZV7nXBjvmfjhBIcw4=
211github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
215github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
212github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
216github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
213github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
217github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
main.go+2
@@ -6,6 +6,8 @@ import (
6
6
7 _ "net/http/pprof" // profiling
7 _ "net/http/pprof" // profiling
8
8
9+ _ "github.com/joho/godotenv/autoload" // automatically load .env files
10+
9 "github.com/charmbracelet/crush/cmd"
11 "github.com/charmbracelet/crush/cmd"
10 "github.com/charmbracelet/crush/internal/logging"
12 "github.com/charmbracelet/crush/internal/logging"
11)
13)
pkg/config/config.gonew file+124
@@ -0,0 +1,124 @@
1+package config
2+
3+import "github.com/charmbracelet/crush/internal/fur/provider"
4+
5+const (
6+ appName = "crush"
7+ defaultDataDirectory = ".crush"
8+ defaultLogLevel = "info"
9+)
10+
11+var defaultContextPaths = []string{
12+ ".github/copilot-instructions.md",
13+ ".cursorrules",
14+ ".cursor/rules/",
15+ "CLAUDE.md",
16+ "CLAUDE.local.md",
17+ "GEMINI.md",
18+ "gemini.md",
19+ "crush.md",
20+ "crush.local.md",
21+ "Crush.md",
22+ "Crush.local.md",
23+ "CRUSH.md",
24+ "CRUSH.local.md",
25+}
26+
27+type SelectedModel struct {
28+ // The model id as used by the provider API.
29+ // Required.
30+ Model string `json:"model"`
31+ // The model provider, same as the key/id used in the providers config.
32+ // Required.
33+ Provider string `json:"provider"`
34+
35+ // Only used by models that use the openai provider and need this set.
36+ ReasoningEffort string `json:"reasoning_effort,omitempty"`
37+
38+ // Overrides the default model configuration.
39+ MaxTokens int64 `json:"max_tokens,omitempty"`
40+
41+ // Used by anthropic models that can reason to indicate if the model should think.
42+ Think bool `json:"think,omitempty"`
43+}
44+
45+type ProviderConfig struct {
46+ // The provider's API endpoint.
47+ BaseURL string `json:"base_url,omitempty"`
48+ // The provider type, e.g. "openai", "anthropic", etc. if empty it defaults to openai.
49+ Type provider.Type `json:"type,omitempty"`
50+ // The provider's API key.
51+ APIKey string `json:"api_key,omitempty"`
52+ // Marks the provider as disabled.
53+ Disable bool `json:"disable,omitempty"`
54+
55+ // Extra headers to send with each request to the provider.
56+ ExtraHeaders map[string]string
57+
58+ // Used to pass extra parameters to the provider.
59+ ExtraParams map[string]string `json:"-"`
60+
61+ // The provider models
62+ Models []provider.Model `json:"models,omitempty"`
63+}
64+
65+type MCPType string
66+
67+const (
68+ MCPStdio MCPType = "stdio"
69+ MCPSse MCPType = "sse"
70+ MCPHttp MCPType = "http"
71+)
72+
73+type MCP struct {
74+ Command string `json:"command,omitempty" `
75+ Env []string `json:"env,omitempty"`
76+ Args []string `json:"args,omitempty"`
77+ Type MCPType `json:"type"`
78+ URL string `json:"url,omitempty"`
79+
80+ // TODO: maybe make it possible to get the value from the env
81+ Headers map[string]string `json:"headers,omitempty"`
82+}
83+
84+type LSPConfig struct {
85+ Disabled bool `json:"enabled,omitempty"`
86+ Command string `json:"command"`
87+ Args []string `json:"args,omitempty"`
88+ Options any `json:"options,omitempty"`
89+}
90+
91+type TUIOptions struct {
92+ CompactMode bool `json:"compact_mode,omitempty"`
93+ // Here we can add themes later or any TUI related options
94+}
95+
96+type Options struct {
97+ ContextPaths []string `json:"context_paths,omitempty"`
98+ TUI *TUIOptions `json:"tui,omitempty"`
99+ Debug bool `json:"debug,omitempty"`
100+ DebugLSP bool `json:"debug_lsp,omitempty"`
101+ DisableAutoSummarize bool `json:"disable_auto_summarize,omitempty"`
102+ // Relative to the cwd
103+ DataDirectory string `json:"data_directory,omitempty"`
104+}
105+
106+// Config holds the configuration for crush.
107+type Config struct {
108+ workingDir string `json:"-"`
109+ // We currently only support large/small as values here.
110+ Models map[string]SelectedModel `json:"models,omitempty"`
111+
112+ // The providers that are configured
113+ Providers map[string]ProviderConfig `json:"providers,omitempty"`
114+
115+ MCP map[string]MCP `json:"mcp,omitempty"`
116+
117+ LSP map[string]LSPConfig `json:"lsp,omitempty"`
118+
119+ Options *Options `json:"options,omitempty"`
120+}
121+
122+func (c *Config) WorkingDir() string {
123+ return c.workingDir
124+}
pkg/config/load.gonew file+277
@@ -0,0 +1,277 @@
1+package config
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+ "io"
7+ "os"
8+ "path/filepath"
9+ "runtime"
10+ "slices"
11+ "strings"
12+
13+ "github.com/charmbracelet/crush/internal/fur/client"
14+ "github.com/charmbracelet/crush/internal/fur/provider"
15+ "github.com/charmbracelet/crush/pkg/env"
16+)
17+
18+// LoadReader config via io.Reader.
19+func LoadReader(fd io.Reader) (*Config, error) {
20+ data, err := io.ReadAll(fd)
21+ if err != nil {
22+ return nil, err
23+ }
24+
25+ var config Config
26+ err = json.Unmarshal(data, &config)
27+ if err != nil {
28+ return nil, err
29+ }
30+ return &config, err
31+}
32+
33+// Load loads the configuration from the default paths.
34+func Load(workingDir string, env env.Env) (*Config, error) {
35+ // uses default config paths
36+ configPaths := []string{
37+ globalConfig(),
38+ globalConfigData(),
39+ filepath.Join(workingDir, fmt.Sprintf("%s.json", appName)),
40+ filepath.Join(workingDir, fmt.Sprintf(".%s.json", appName)),
41+ }
42+ cfg, err := loadFromConfigPaths(configPaths)
43+ if err != nil {
44+ return nil, fmt.Errorf("failed to load config: %w", err)
45+ }
46+ // TODO: maybe add a validation step here right after loading
47+ // e.x validate the models
48+ // e.x validate provider config
49+
50+ setDefaults(workingDir, cfg)
51+
52+ // Load known providers, this loads the config from fur
53+ providers, err := LoadProviders(client.New())
54+ if err != nil {
55+ return nil, fmt.Errorf("failed to load providers: %w", err)
56+ }
57+
58+ // Configure providers
59+ valueResolver := NewShellVariableResolver(env)
60+ if err := configureProviders(cfg, env, valueResolver, providers); err != nil {
61+ return nil, fmt.Errorf("failed to configure providers: %w", err)
62+ }
63+
64+ return cfg, nil
65+}
66+
67+func configureProviders(cfg *Config, env env.Env, resolver VariableResolver, knownProviders []provider.Provider) error {
68+ for _, p := range knownProviders {
69+
70+ config, ok := cfg.Providers[string(p.ID)]
71+ // if the user configured a known provider we need to allow it to override a couple of parameters
72+ if ok {
73+ if config.BaseURL != "" {
74+ p.APIEndpoint = config.BaseURL
75+ }
76+ if config.APIKey != "" {
77+ p.APIKey = config.APIKey
78+ }
79+ if len(config.Models) > 0 {
80+ models := []provider.Model{}
81+ seen := make(map[string]bool)
82+
83+ for _, model := range config.Models {
84+ if seen[model.ID] {
85+ continue
86+ }
87+ seen[model.ID] = true
88+ models = append(models, model)
89+ }
90+ for _, model := range p.Models {
91+ if seen[model.ID] {
92+ continue
93+ }
94+ seen[model.ID] = true
95+ models = append(models, model)
96+ }
97+
98+ p.Models = models
99+ }
100+ }
101+ prepared := ProviderConfig{
102+ BaseURL: p.APIEndpoint,
103+ APIKey: p.APIKey,
104+ Type: p.Type,
105+ Disable: config.Disable,
106+ ExtraHeaders: config.ExtraHeaders,
107+ ExtraParams: make(map[string]string),
108+ Models: p.Models,
109+ }
110+
111+ switch p.ID {
112+ // Handle specific providers that require additional configuration
113+ case provider.InferenceProviderVertexAI:
114+ if !hasVertexCredentials(env) {
115+ continue
116+ }
117+ prepared.ExtraParams["project"] = env.Get("GOOGLE_CLOUD_PROJECT")
118+ prepared.ExtraParams["location"] = env.Get("GOOGLE_CLOUD_LOCATION")
119+ case provider.InferenceProviderBedrock:
120+ if !hasAWSCredentials(env) {
121+ continue
122+ }
123+ for _, model := range p.Models {
124+ if !strings.HasPrefix(model.ID, "anthropic.") {
125+ return fmt.Errorf("bedrock provider only supports anthropic models for now, found: %s", model.ID)
126+ }
127+ }
128+ default:
129+ // if the provider api or endpoint are missing we skip them
130+ v, err := resolver.ResolveValue(p.APIKey)
131+ if v == "" || err != nil {
132+ continue
133+ }
134+ v, err = resolver.ResolveValue(p.APIEndpoint)
135+ if v == "" || err != nil {
136+ continue
137+ }
138+ }
139+ cfg.Providers[string(p.ID)] = prepared
140+ }
141+ return nil
142+}
143+
144+func hasVertexCredentials(env env.Env) bool {
145+ useVertex := env.Get("GOOGLE_GENAI_USE_VERTEXAI") == "true"
146+ hasProject := env.Get("GOOGLE_CLOUD_PROJECT") != ""
147+ hasLocation := env.Get("GOOGLE_CLOUD_LOCATION") != ""
148+ return useVertex && hasProject && hasLocation
149+}
150+
151+func hasAWSCredentials(env env.Env) bool {
152+ if env.Get("AWS_ACCESS_KEY_ID") != "" && env.Get("AWS_SECRET_ACCESS_KEY") != "" {
153+ return true
154+ }
155+
156+ if env.Get("AWS_PROFILE") != "" || env.Get("AWS_DEFAULT_PROFILE") != "" {
157+ return true
158+ }
159+
160+ if env.Get("AWS_REGION") != "" || env.Get("AWS_DEFAULT_REGION") != "" {
161+ return true
162+ }
163+
164+ if env.Get("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI") != "" ||
165+ env.Get("AWS_CONTAINER_CREDENTIALS_FULL_URI") != "" {
166+ return true
167+ }
168+
169+ return false
170+}
171+
172+func setDefaults(workingDir string, cfg *Config) {
173+ cfg.workingDir = workingDir
174+ if cfg.Options == nil {
175+ cfg.Options = &Options{}
176+ }
177+ if cfg.Options.TUI == nil {
178+ cfg.Options.TUI = &TUIOptions{}
179+ }
180+ if cfg.Options.ContextPaths == nil {
181+ cfg.Options.ContextPaths = []string{}
182+ }
183+ if cfg.Options.DataDirectory == "" {
184+ cfg.Options.DataDirectory = filepath.Join(workingDir, defaultDataDirectory)
185+ }
186+ if cfg.Providers == nil {
187+ cfg.Providers = make(map[string]ProviderConfig)
188+ }
189+ if cfg.Models == nil {
190+ cfg.Models = make(map[string]SelectedModel)
191+ }
192+ if cfg.MCP == nil {
193+ cfg.MCP = make(map[string]MCP)
194+ }
195+ if cfg.LSP == nil {
196+ cfg.LSP = make(map[string]LSPConfig)
197+ }
198+
199+ // Add the default context paths if they are not already present
200+ cfg.Options.ContextPaths = append(defaultContextPaths, cfg.Options.ContextPaths...)
201+ slices.Sort(cfg.Options.ContextPaths)
202+ cfg.Options.ContextPaths = slices.Compact(cfg.Options.ContextPaths)
203+}
204+
205+func loadFromConfigPaths(configPaths []string) (*Config, error) {
206+ var configs []io.Reader
207+
208+ for _, path := range configPaths {
209+ fd, err := os.Open(path)
210+ if err != nil {
211+ if os.IsNotExist(err) {
212+ continue
213+ }
214+ return nil, fmt.Errorf("failed to open config file %s: %w", path, err)
215+ }
216+ defer fd.Close()
217+
218+ configs = append(configs, fd)
219+ }
220+
221+ return loadFromReaders(configs)
222+}
223+
224+func loadFromReaders(readers []io.Reader) (*Config, error) {
225+ if len(readers) == 0 {
226+ return nil, fmt.Errorf("no configuration readers provided")
227+ }
228+
229+ merged, err := Merge(readers)
230+ if err != nil {
231+ return nil, fmt.Errorf("failed to merge configuration readers: %w", err)
232+ }
233+
234+ return LoadReader(merged)
235+}
236+
237+func globalConfig() string {
238+ xdgConfigHome := os.Getenv("XDG_CONFIG_HOME")
239+ if xdgConfigHome != "" {
240+ return filepath.Join(xdgConfigHome, "crush")
241+ }
242+
243+ // return the path to the main config directory
244+ // for windows, it should be in `%LOCALAPPDATA%/crush/`
245+ // for linux and macOS, it should be in `$HOME/.config/crush/`
246+ if runtime.GOOS == "windows" {
247+ localAppData := os.Getenv("LOCALAPPDATA")
248+ if localAppData == "" {
249+ localAppData = filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local")
250+ }
251+ return filepath.Join(localAppData, appName)
252+ }
253+
254+ return filepath.Join(os.Getenv("HOME"), ".config", appName, fmt.Sprintf("%s.json", appName))
255+}
256+
257+// globalConfigData returns the path to the main data directory for the application.
258+// this config is used when the app overrides configurations instead of updating the global config.
259+func globalConfigData() string {
260+ xdgDataHome := os.Getenv("XDG_DATA_HOME")
261+ if xdgDataHome != "" {
262+ return filepath.Join(xdgDataHome, appName)
263+ }
264+
265+ // return the path to the main data directory
266+ // for windows, it should be in `%LOCALAPPDATA%/crush/`
267+ // for linux and macOS, it should be in `$HOME/.local/share/crush/`
268+ if runtime.GOOS == "windows" {
269+ localAppData := os.Getenv("LOCALAPPDATA")
270+ if localAppData == "" {
271+ localAppData = filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local")
272+ }
273+ return filepath.Join(localAppData, appName)
274+ }
275+
276+ return filepath.Join(os.Getenv("HOME"), ".local", "share", appName, fmt.Sprintf("%s.json", appName))
277+}
pkg/config/load_test.gonew file+316
@@ -0,0 +1,316 @@
1+package config
2+
3+import (
4+ "io"
5+ "strings"
6+ "testing"
7+
8+ "github.com/charmbracelet/crush/internal/fur/provider"
9+ "github.com/charmbracelet/crush/pkg/env"
10+ "github.com/stretchr/testify/assert"
11+)
12+
13+func TestConfig_LoadFromReaders(t *testing.T) {
14+ data1 := strings.NewReader(`{"providers": {"openai": {"api_key": "key1", "base_url": "https://api.openai.com/v1"}}}`)
15+ data2 := strings.NewReader(`{"providers": {"openai": {"api_key": "key2", "base_url": "https://api.openai.com/v2"}}}`)
16+ data3 := strings.NewReader(`{"providers": {"openai": {}}}`)
17+
18+ loadedConfig, err := loadFromReaders([]io.Reader{data1, data2, data3})
19+
20+ assert.NoError(t, err)
21+ assert.NotNil(t, loadedConfig)
22+ assert.Len(t, loadedConfig.Providers, 1)
23+ assert.Equal(t, "key2", loadedConfig.Providers["openai"].APIKey)
24+ assert.Equal(t, "https://api.openai.com/v2", loadedConfig.Providers["openai"].BaseURL)
25+}
26+
27+func TestConfig_setDefaults(t *testing.T) {
28+ cfg := &Config{}
29+
30+ setDefaults("/tmp", cfg)
31+
32+ assert.NotNil(t, cfg.Options)
33+ assert.NotNil(t, cfg.Options.TUI)
34+ assert.NotNil(t, cfg.Options.ContextPaths)
35+ assert.NotNil(t, cfg.Providers)
36+ assert.NotNil(t, cfg.Models)
37+ assert.NotNil(t, cfg.LSP)
38+ assert.NotNil(t, cfg.MCP)
39+ assert.Equal(t, "/tmp/.crush", cfg.Options.DataDirectory)
40+ for _, path := range defaultContextPaths {
41+ assert.Contains(t, cfg.Options.ContextPaths, path)
42+ }
43+ assert.Equal(t, "/tmp", cfg.workingDir)
44+}
45+
46+func TestConfig_configureProviders(t *testing.T) {
47+ knownProviders := []provider.Provider{
48+ {
49+ ID: "openai",
50+ APIKey: "$OPENAI_API_KEY",
51+ APIEndpoint: "https://api.openai.com/v1",
52+ Models: []provider.Model{{
53+ ID: "test-model",
54+ }},
55+ },
56+ }
57+
58+ cfg := &Config{}
59+ setDefaults("/tmp", cfg)
60+ env := env.NewFromMap(map[string]string{
61+ "OPENAI_API_KEY": "test-key",
62+ })
63+ resolver := NewEnvironmentVariableResolver(env)
64+ err := configureProviders(cfg, env, resolver, knownProviders)
65+ assert.NoError(t, err)
66+ assert.Len(t, cfg.Providers, 1)
67+
68+ // We want to make sure that we keep the configured API key as a placeholder
69+ assert.Equal(t, "$OPENAI_API_KEY", cfg.Providers["openai"].APIKey)
70+}
71+
72+func TestConfig_configureProvidersWithOverride(t *testing.T) {
73+ knownProviders := []provider.Provider{
74+ {
75+ ID: "openai",
76+ APIKey: "$OPENAI_API_KEY",
77+ APIEndpoint: "https://api.openai.com/v1",
78+ Models: []provider.Model{{
79+ ID: "test-model",
80+ }},
81+ },
82+ }
83+
84+ cfg := &Config{
85+ Providers: map[string]ProviderConfig{
86+ "openai": {
87+ APIKey: "xyz",
88+ BaseURL: "https://api.openai.com/v2",
89+ Models: []provider.Model{
90+ {
91+ ID: "test-model",
92+ Name: "Updated",
93+ },
94+ {
95+ ID: "another-model",
96+ },
97+ },
98+ },
99+ },
100+ }
101+ setDefaults("/tmp", cfg)
102+ env := env.NewFromMap(map[string]string{
103+ "OPENAI_API_KEY": "test-key",
104+ })
105+ resolver := NewEnvironmentVariableResolver(env)
106+ err := configureProviders(cfg, env, resolver, knownProviders)
107+ assert.NoError(t, err)
108+ assert.Len(t, cfg.Providers, 1)
109+
110+ // We want to make sure that we keep the configured API key as a placeholder
111+ assert.Equal(t, "xyz", cfg.Providers["openai"].APIKey)
112+ assert.Equal(t, "https://api.openai.com/v2", cfg.Providers["openai"].BaseURL)
113+ assert.Len(t, cfg.Providers["openai"].Models, 2)
114+ assert.Equal(t, "Updated", cfg.Providers["openai"].Models[0].Name)
115+}
116+
117+func TestConfig_configureProvidersWithNewProvider(t *testing.T) {
118+ knownProviders := []provider.Provider{
119+ {
120+ ID: "openai",
121+ APIKey: "$OPENAI_API_KEY",
122+ APIEndpoint: "https://api.openai.com/v1",
123+ Models: []provider.Model{{
124+ ID: "test-model",
125+ }},
126+ },
127+ }
128+
129+ cfg := &Config{
130+ Providers: map[string]ProviderConfig{
131+ "custom": {
132+ APIKey: "xyz",
133+ BaseURL: "https://api.someendpoint.com/v2",
134+ Models: []provider.Model{
135+ {
136+ ID: "test-model",
137+ },
138+ },
139+ },
140+ },
141+ }
142+ setDefaults("/tmp", cfg)
143+ env := env.NewFromMap(map[string]string{
144+ "OPENAI_API_KEY": "test-key",
145+ })
146+ resolver := NewEnvironmentVariableResolver(env)
147+ err := configureProviders(cfg, env, resolver, knownProviders)
148+ assert.NoError(t, err)
149+ // Should be to because of the env variable
150+ assert.Len(t, cfg.Providers, 2)
151+
152+ // We want to make sure that we keep the configured API key as a placeholder
153+ assert.Equal(t, "xyz", cfg.Providers["custom"].APIKey)
154+ assert.Equal(t, "https://api.someendpoint.com/v2", cfg.Providers["custom"].BaseURL)
155+ assert.Len(t, cfg.Providers["custom"].Models, 1)
156+
157+ _, ok := cfg.Providers["openai"]
158+ assert.True(t, ok, "OpenAI provider should still be present")
159+}
160+
161+func TestConfig_configureProvidersBedrockWithCredentials(t *testing.T) {
162+ knownProviders := []provider.Provider{
163+ {
164+ ID: provider.InferenceProviderBedrock,
165+ APIKey: "",
166+ APIEndpoint: "",
167+ Models: []provider.Model{{
168+ ID: "anthropic.claude-sonnet-4-20250514-v1:0",
169+ }},
170+ },
171+ }
172+
173+ cfg := &Config{}
174+ setDefaults("/tmp", cfg)
175+ env := env.NewFromMap(map[string]string{
176+ "AWS_ACCESS_KEY_ID": "test-key-id",
177+ "AWS_SECRET_ACCESS_KEY": "test-secret-key",
178+ })
179+ resolver := NewEnvironmentVariableResolver(env)
180+ err := configureProviders(cfg, env, resolver, knownProviders)
181+ assert.NoError(t, err)
182+ assert.Len(t, cfg.Providers, 1)
183+
184+ bedrockProvider, ok := cfg.Providers["bedrock"]
185+ assert.True(t, ok, "Bedrock provider should be present")
186+ assert.Len(t, bedrockProvider.Models, 1)
187+ assert.Equal(t, "anthropic.claude-sonnet-4-20250514-v1:0", bedrockProvider.Models[0].ID)
188+}
189+
190+func TestConfig_configureProvidersBedrockWithoutCredentials(t *testing.T) {
191+ knownProviders := []provider.Provider{
192+ {
193+ ID: provider.InferenceProviderBedrock,
194+ APIKey: "",
195+ APIEndpoint: "",
196+ Models: []provider.Model{{
197+ ID: "anthropic.claude-sonnet-4-20250514-v1:0",
198+ }},
199+ },
200+ }
201+
202+ cfg := &Config{}
203+ setDefaults("/tmp", cfg)
204+ env := env.NewFromMap(map[string]string{})
205+ resolver := NewEnvironmentVariableResolver(env)
206+ err := configureProviders(cfg, env, resolver, knownProviders)
207+ assert.NoError(t, err)
208+ // Provider should not be configured without credentials
209+ assert.Len(t, cfg.Providers, 0)
210+}
211+
212+func TestConfig_configureProvidersBedrockWithoutUnsupportedModel(t *testing.T) {
213+ knownProviders := []provider.Provider{
214+ {
215+ ID: provider.InferenceProviderBedrock,
216+ APIKey: "",
217+ APIEndpoint: "",
218+ Models: []provider.Model{{
219+ ID: "some-random-model",
220+ }},
221+ },
222+ }
223+
224+ cfg := &Config{}
225+ setDefaults("/tmp", cfg)
226+ env := env.NewFromMap(map[string]string{
227+ "AWS_ACCESS_KEY_ID": "test-key-id",
228+ "AWS_SECRET_ACCESS_KEY": "test-secret-key",
229+ })
230+ resolver := NewEnvironmentVariableResolver(env)
231+ err := configureProviders(cfg, env, resolver, knownProviders)
232+ assert.Error(t, err)
233+}
234+
235+func TestConfig_configureProvidersVertexAIWithCredentials(t *testing.T) {
236+ knownProviders := []provider.Provider{
237+ {
238+ ID: provider.InferenceProviderVertexAI,
239+ APIKey: "",
240+ APIEndpoint: "",
241+ Models: []provider.Model{{
242+ ID: "gemini-pro",
243+ }},
244+ },
245+ }
246+
247+ cfg := &Config{}
248+ setDefaults("/tmp", cfg)
249+ env := env.NewFromMap(map[string]string{
250+ "GOOGLE_GENAI_USE_VERTEXAI": "true",
251+ "GOOGLE_CLOUD_PROJECT": "test-project",
252+ "GOOGLE_CLOUD_LOCATION": "us-central1",
253+ })
254+ resolver := NewEnvironmentVariableResolver(env)
255+ err := configureProviders(cfg, env, resolver, knownProviders)
256+ assert.NoError(t, err)
257+ assert.Len(t, cfg.Providers, 1)
258+
259+ vertexProvider, ok := cfg.Providers["vertexai"]
260+ assert.True(t, ok, "VertexAI provider should be present")
261+ assert.Len(t, vertexProvider.Models, 1)
262+ assert.Equal(t, "gemini-pro", vertexProvider.Models[0].ID)
263+ assert.Equal(t, "test-project", vertexProvider.ExtraParams["project"])
264+ assert.Equal(t, "us-central1", vertexProvider.ExtraParams["location"])
265+}
266+
267+func TestConfig_configureProvidersVertexAIWithoutCredentials(t *testing.T) {
268+ knownProviders := []provider.Provider{
269+ {
270+ ID: provider.InferenceProviderVertexAI,
271+ APIKey: "",
272+ APIEndpoint: "",
273+ Models: []provider.Model{{
274+ ID: "gemini-pro",
275+ }},
276+ },
277+ }
278+
279+ cfg := &Config{}
280+ setDefaults("/tmp", cfg)
281+ env := env.NewFromMap(map[string]string{
282+ "GOOGLE_GENAI_USE_VERTEXAI": "false",
283+ "GOOGLE_CLOUD_PROJECT": "test-project",
284+ "GOOGLE_CLOUD_LOCATION": "us-central1",
285+ })
286+ resolver := NewEnvironmentVariableResolver(env)
287+ err := configureProviders(cfg, env, resolver, knownProviders)
288+ assert.NoError(t, err)
289+ // Provider should not be configured without proper credentials
290+ assert.Len(t, cfg.Providers, 0)
291+}
292+
293+func TestConfig_configureProvidersVertexAIMissingProject(t *testing.T) {
294+ knownProviders := []provider.Provider{
295+ {
296+ ID: provider.InferenceProviderVertexAI,
297+ APIKey: "",
298+ APIEndpoint: "",
299+ Models: []provider.Model{{
300+ ID: "gemini-pro",
301+ }},
302+ },
303+ }
304+
305+ cfg := &Config{}
306+ setDefaults("/tmp", cfg)
307+ env := env.NewFromMap(map[string]string{
308+ "GOOGLE_GENAI_USE_VERTEXAI": "true",
309+ "GOOGLE_CLOUD_LOCATION": "us-central1",
310+ })
311+ resolver := NewEnvironmentVariableResolver(env)
312+ err := configureProviders(cfg, env, resolver, knownProviders)
313+ assert.NoError(t, err)
314+ // Provider should not be configured without project
315+ assert.Len(t, cfg.Providers, 0)
316+}
pkg/config/merge.gonew file+16
@@ -0,0 +1,16 @@
1+package config
2+
3+import (
4+ "bytes"
5+ "io"
6+
7+ "github.com/qjebbs/go-jsons"
8+)
9+
10+func Merge(data []io.Reader) (io.Reader, error) {
11+ got, err := jsons.Merge(data)
12+ if err != nil {
13+ return nil, err
14+ }
15+ return bytes.NewReader(got), nil
16+}
pkg/config/merge_test.gonew file+27
@@ -0,0 +1,27 @@
1+package config
2+
3+import (
4+ "io"
5+ "strings"
6+ "testing"
7+)
8+
9+func TestMerge(t *testing.T) {
10+ data1 := strings.NewReader(`{"foo": "bar"}`)
11+ data2 := strings.NewReader(`{"baz": "qux"}`)
12+
13+ merged, err := Merge([]io.Reader{data1, data2})
14+ if err != nil {
15+ t.Fatalf("expected no error, got %v", err)
16+ }
17+
18+ expected := `{"baz":"qux","foo":"bar"}`
19+ got, err := io.ReadAll(merged)
20+ if err != nil {
21+ t.Fatalf("expected no error reading merged data, got %v", err)
22+ }
23+
24+ if string(got) != expected {
25+ t.Errorf("expected %s, got %s", expected, string(got))
26+ }
27+}
pkg/config/provider.gonew file+93
@@ -0,0 +1,93 @@
1+package config
2+
3+import (
4+ "encoding/json"
5+ "os"
6+ "path/filepath"
7+ "runtime"
8+ "sync"
9+
10+ "github.com/charmbracelet/crush/internal/fur/provider"
11+)
12+
13+type ProviderClient interface {
14+ GetProviders() ([]provider.Provider, error)
15+}
16+
17+var (
18+ providerOnce sync.Once
19+ providerList []provider.Provider
20+)
21+
22+// file to cache provider data
23+func providerCacheFileData() string {
24+ xdgDataHome := os.Getenv("XDG_DATA_HOME")
25+ if xdgDataHome != "" {
26+ return filepath.Join(xdgDataHome, appName)
27+ }
28+
29+ // return the path to the main data directory
30+ // for windows, it should be in `%LOCALAPPDATA%/crush/`
31+ // for linux and macOS, it should be in `$HOME/.local/share/crush/`
32+ if runtime.GOOS == "windows" {
33+ localAppData := os.Getenv("LOCALAPPDATA")
34+ if localAppData == "" {
35+ localAppData = filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local")
36+ }
37+ return filepath.Join(localAppData, appName)
38+ }
39+
40+ return filepath.Join(os.Getenv("HOME"), ".local", "share", appName, "providers.json")
41+}
42+
43+func saveProvidersInCache(path string, providers []provider.Provider) error {
44+ dir := filepath.Dir(path)
45+ if err := os.MkdirAll(dir, 0o755); err != nil {
46+ return err
47+ }
48+
49+ data, err := json.MarshalIndent(providers, "", " ")
50+ if err != nil {
51+ return err
52+ }
53+
54+ return os.WriteFile(path, data, 0o644)
55+}
56+
57+func loadProvidersFromCache(path string) ([]provider.Provider, error) {
58+ data, err := os.ReadFile(path)
59+ if err != nil {
60+ return nil, err
61+ }
62+
63+ var providers []provider.Provider
64+ err = json.Unmarshal(data, &providers)
65+ return providers, err
66+}
67+
68+func loadProviders(path string, client ProviderClient) ([]provider.Provider, error) {
69+ providers, err := client.GetProviders()
70+ if err != nil {
71+ fallbackToCache, err := loadProvidersFromCache(path)
72+ if err != nil {
73+ return nil, err
74+ }
75+ providers = fallbackToCache
76+ } else {
77+ if err := saveProvidersInCache(path, providerList); err != nil {
78+ return nil, err
79+ }
80+ }
81+ return providers, nil
82+}
83+
84+func LoadProviders(client ProviderClient) ([]provider.Provider, error) {
85+ var err error
86+ providerOnce.Do(func() {
87+ providerList, err = loadProviders(providerCacheFileData(), client)
88+ })
89+ if err != nil {
90+ return nil, err
91+ }
92+ return providerList, nil
93+}
pkg/config/provider_test.gonew file+74
@@ -0,0 +1,74 @@
1+package config
2+
3+import (
4+ "encoding/json"
5+ "errors"
6+ "os"
7+ "testing"
8+
9+ "github.com/charmbracelet/crush/internal/fur/provider"
10+ "github.com/stretchr/testify/assert"
11+)
12+
13+type mockProviderClient struct {
14+ shouldFail bool
15+}
16+
17+func (m *mockProviderClient) GetProviders() ([]provider.Provider, error) {
18+ if m.shouldFail {
19+ return nil, errors.New("failed to load providers")
20+ }
21+ return []provider.Provider{
22+ {
23+ Name: "Mock",
24+ },
25+ }, nil
26+}
27+
28+func TestProvider_loadProvidersNoIssues(t *testing.T) {
29+ client := &mockProviderClient{shouldFail: false}
30+ tmpPath := t.TempDir() + "/providers.json"
31+ providers, err := loadProviders(tmpPath, client)
32+ assert.NoError(t, err)
33+ assert.NotNil(t, providers)
34+ assert.Len(t, providers, 1)
35+
36+ // check if file got saved
37+ fileInfo, err := os.Stat(tmpPath)
38+ assert.NoError(t, err)
39+ assert.False(t, fileInfo.IsDir(), "Expected a file, not a directory")
40+}
41+
42+func TestProvider_loadProvidersWithIssues(t *testing.T) {
43+ client := &mockProviderClient{shouldFail: true}
44+ tmpPath := t.TempDir() + "/providers.json"
45+ // store providers to a temporary file
46+ oldProviders := []provider.Provider{
47+ {
48+ Name: "OldProvider",
49+ },
50+ }
51+ data, err := json.Marshal(oldProviders)
52+ if err != nil {
53+ t.Fatalf("Failed to marshal old providers: %v", err)
54+ }
55+
56+ err = os.WriteFile(tmpPath, data, 0o644)
57+ if err != nil {
58+ t.Fatalf("Failed to write old providers to file: %v", err)
59+ }
60+ providers, err := loadProviders(tmpPath, client)
61+ assert.NoError(t, err)
62+ assert.NotNil(t, providers)
63+ assert.Len(t, providers, 1)
64+ assert.Equal(t, "OldProvider", providers[0].Name, "Expected to keep old provider when loading fails")
65+}
66+
67+func TestProvider_loadProvidersWithIssuesAndNoCache(t *testing.T) {
68+ client := &mockProviderClient{shouldFail: true}
69+ tmpPath := t.TempDir() + "/providers.json"
70+ providers, err := loadProviders(tmpPath, client)
71+ assert.Error(t, err)
72+ assert.Nil(t, providers, "Expected nil providers when loading fails and no cache exists")
73+}
74+
pkg/config/resolve.gonew file+89
@@ -0,0 +1,89 @@
1+package config
2+
3+import (
4+ "context"
5+ "fmt"
6+ "strings"
7+ "time"
8+
9+ "github.com/charmbracelet/crush/internal/shell"
10+ "github.com/charmbracelet/crush/pkg/env"
11+)
12+
13+type VariableResolver interface {
14+ ResolveValue(value string) (string, error)
15+}
16+
17+type Shell interface {
18+ Exec(ctx context.Context, command string) (stdout, stderr string, err error)
19+}
20+
21+type shellVariableResolver struct {
22+ shell Shell
23+ env env.Env
24+}
25+
26+func NewShellVariableResolver(env env.Env) VariableResolver {
27+ return &shellVariableResolver{
28+ shell: shell.NewShell(
29+ &shell.Options{
30+ Env: env.Env(),
31+ },
32+ ),
33+ }
34+}
35+
36+// ResolveValue is a method for resolving values, such as environment variables.
37+// it will expect strings that start with `$` to be resolved as environment variables or shell commands.
38+// if the string does not start with `$`, it will return the string as is.
39+func (r *shellVariableResolver) ResolveValue(value string) (string, error) {
40+ if !strings.HasPrefix(value, "$") {
41+ return value, nil
42+ }
43+
44+ if strings.HasPrefix(value, "$(") && strings.HasSuffix(value, ")") {
45+ command := strings.TrimSuffix(strings.TrimPrefix(value, "$("), ")")
46+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
47+ defer cancel()
48+
49+ stdout, _, err := r.shell.Exec(ctx, command)
50+ if err != nil {
51+ return "", fmt.Errorf("command execution failed: %w", err)
52+ }
53+ return strings.TrimSpace(stdout), nil
54+ }
55+
56+ if strings.HasPrefix(value, "$") {
57+ varName := strings.TrimPrefix(value, "$")
58+ value = r.env.Get(varName)
59+ if value == "" {
60+ return "", fmt.Errorf("environment variable %q not set", varName)
61+ }
62+ return value, nil
63+ }
64+ return "", fmt.Errorf("invalid value format: %s", value)
65+}
66+
67+type environmentVariableResolver struct {
68+ env env.Env
69+}
70+
71+func NewEnvironmentVariableResolver(env env.Env) VariableResolver {
72+ return &environmentVariableResolver{
73+ env: env,
74+ }
75+}
76+
77+// ResolveValue resolves environment variables from the provided env.Env.
78+func (r *environmentVariableResolver) ResolveValue(value string) (string, error) {
79+ if !strings.HasPrefix(value, "$") {
80+ return value, nil
81+ }
82+
83+ varName := strings.TrimPrefix(value, "$")
84+ resolvedValue := r.env.Get(varName)
85+ if resolvedValue == "" {
86+ return "", fmt.Errorf("environment variable %q not set", varName)
87+ }
88+ return resolvedValue, nil
89+}
pkg/config/resolve_test.gonew file+178
@@ -0,0 +1,178 @@
1+package config
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+
8+ "github.com/charmbracelet/crush/pkg/env"
9+ "github.com/stretchr/testify/assert"
10+)
11+
12+// mockShell implements the Shell interface for testing
13+type mockShell struct {
14+ execFunc func(ctx context.Context, command string) (stdout, stderr string, err error)
15+}
16+
17+func (m *mockShell) Exec(ctx context.Context, command string) (stdout, stderr string, err error) {
18+ if m.execFunc != nil {
19+ return m.execFunc(ctx, command)
20+ }
21+ return "", "", nil
22+}
23+
24+func TestShellVariableResolver_ResolveValue(t *testing.T) {
25+ tests := []struct {
26+ name string
27+ value string
28+ envVars map[string]string
29+ shellFunc func(ctx context.Context, command string) (stdout, stderr string, err error)
30+ expected string
31+ expectError bool
32+ }{
33+ {
34+ name: "non-variable string returns as-is",
35+ value: "plain-string",
36+ expected: "plain-string",
37+ },
38+ {
39+ name: "environment variable resolution",
40+ value: "$HOME",
41+ envVars: map[string]string{"HOME": "/home/user"},
42+ expected: "/home/user",
43+ },
44+ {
45+ name: "missing environment variable returns error",
46+ value: "$MISSING_VAR",
47+ envVars: map[string]string{},
48+ expectError: true,
49+ },
50+ {
51+ name: "shell command execution",
52+ value: "$(echo hello)",
53+ shellFunc: func(ctx context.Context, command string) (stdout, stderr string, err error) {
54+ if command == "echo hello" {
55+ return "hello\n", "", nil
56+ }
57+ return "", "", errors.New("unexpected command")
58+ },
59+ expected: "hello",
60+ },
61+ {
62+ name: "shell command with whitespace trimming",
63+ value: "$(echo ' spaced ')",
64+ shellFunc: func(ctx context.Context, command string) (stdout, stderr string, err error) {
65+ if command == "echo ' spaced '" {
66+ return " spaced \n", "", nil
67+ }
68+ return "", "", errors.New("unexpected command")
69+ },
70+ expected: "spaced",
71+ },
72+ {
73+ name: "shell command execution error",
74+ value: "$(false)",
75+ shellFunc: func(ctx context.Context, command string) (stdout, stderr string, err error) {
76+ return "", "", errors.New("command failed")
77+ },
78+ expectError: true,
79+ },
80+ {
81+ name: "invalid format returns error",
82+ value: "$",
83+ expectError: true,
84+ },
85+ }
86+
87+ for _, tt := range tests {
88+ t.Run(tt.name, func(t *testing.T) {
89+ testEnv := env.NewFromMap(tt.envVars)
90+ resolver := &shellVariableResolver{
91+ shell: &mockShell{execFunc: tt.shellFunc},
92+ env: testEnv,
93+ }
94+
95+ result, err := resolver.ResolveValue(tt.value)
96+
97+ if tt.expectError {
98+ assert.Error(t, err)
99+ } else {
100+ assert.NoError(t, err)
101+ assert.Equal(t, tt.expected, result)
102+ }
103+ })
104+ }
105+}
106+
107+func TestEnvironmentVariableResolver_ResolveValue(t *testing.T) {
108+ tests := []struct {
109+ name string
110+ value string
111+ envVars map[string]string
112+ expected string
113+ expectError bool
114+ }{
115+ {
116+ name: "non-variable string returns as-is",
117+ value: "plain-string",
118+ expected: "plain-string",
119+ },
120+ {
121+ name: "environment variable resolution",
122+ value: "$HOME",
123+ envVars: map[string]string{"HOME": "/home/user"},
124+ expected: "/home/user",
125+ },
126+ {
127+ name: "environment variable with complex value",
128+ value: "$PATH",
129+ envVars: map[string]string{"PATH": "/usr/bin:/bin:/usr/local/bin"},
130+ expected: "/usr/bin:/bin:/usr/local/bin",
131+ },
132+ {
133+ name: "missing environment variable returns error",
134+ value: "$MISSING_VAR",
135+ envVars: map[string]string{},
136+ expectError: true,
137+ },
138+ {
139+ name: "empty environment variable returns error",
140+ value: "$EMPTY_VAR",
141+ envVars: map[string]string{"EMPTY_VAR": ""},
142+ expectError: true,
143+ },
144+ }
145+
146+ for _, tt := range tests {
147+ t.Run(tt.name, func(t *testing.T) {
148+ testEnv := env.NewFromMap(tt.envVars)
149+ resolver := NewEnvironmentVariableResolver(testEnv)
150+
151+ result, err := resolver.ResolveValue(tt.value)
152+
153+ if tt.expectError {
154+ assert.Error(t, err)
155+ } else {
156+ assert.NoError(t, err)
157+ assert.Equal(t, tt.expected, result)
158+ }
159+ })
160+ }
161+}
162+
163+func TestNewShellVariableResolver(t *testing.T) {
164+ testEnv := env.NewFromMap(map[string]string{"TEST": "value"})
165+ resolver := NewShellVariableResolver(testEnv)
166+
167+ assert.NotNil(t, resolver)
168+ assert.Implements(t, (*VariableResolver)(nil), resolver)
169+}
170+
171+func TestNewEnvironmentVariableResolver(t *testing.T) {
172+ testEnv := env.NewFromMap(map[string]string{"TEST": "value"})
173+ resolver := NewEnvironmentVariableResolver(testEnv)
174+
175+ assert.NotNil(t, resolver)
176+ assert.Implements(t, (*VariableResolver)(nil), resolver)
177+}
178+
pkg/env/env.gonew file+55
@@ -0,0 +1,55 @@
1+package env
2+
3+import "os"
4+
5+type Env interface {
6+ Get(key string) string
7+ Env() []string
8+}
9+
10+type osEnv struct{}
11+
12+// Get implements Env.
13+func (o *osEnv) Get(key string) string {
14+ return os.Getenv(key)
15+}
16+
17+func (o *osEnv) Env() []string {
18+ env := os.Environ()
19+ if len(env) == 0 {
20+ return nil
21+ }
22+ return env
23+}
24+
25+func New() Env {
26+ return &osEnv{}
27+}
28+
29+type mapEnv struct {
30+ m map[string]string
31+}
32+
33+// Get implements Env.
34+func (m *mapEnv) Get(key string) string {
35+ if value, ok := m.m[key]; ok {
36+ return value
37+ }
38+ return ""
39+}
40+
41+// Env implements Env.
42+func (m *mapEnv) Env() []string {
43+ if len(m.m) == 0 {
44+ return nil
45+ }
46+ env := make([]string, 0, len(m.m))
47+ for k, v := range m.m {
48+ env = append(env, k+"="+v)
49+ }
50+ return env
51+}
52+
53+func NewFromMap(m map[string]string) Env {
54+ return &mapEnv{m: m}
55+}
todos.mddeleted−51
@@ -1,51 +0,0 @@
1−## TODOs before release
2−
3−- [x] Implement help
4− - [x] Show full help
5− - [x] Make help dependent on the focused pane and page
6−- [x] Implement current model in the sidebar
7−- [x] Implement LSP errors
8−- [x] Implement changed files
9− - [x] Implement initial load
10− - [x] Implement realtime file changes
11−- [ ] Events when tool error
12−- [ ] Support bash commands
13−- [ ] Editor attachments fixes
14− - [ ] Reimplement removing attachments
15−- [ ] Fix the logs view
16− - [ ] Review the implementation
17− - [ ] The page lags
18− - [ ] Make the logs long lived ?
19−- [ ] Add all possible actions to the commands
20−- [ ] Parallel tool calls and permissions
21− - [ ] Run the tools in parallel and add results in parallel
22− - [ ] Show multiple permissions dialogs
23−- [ ] Add another space around buttons
24−- [ ] Completions
25− - [ ] Should change the help to show the completions stuff
26− - [ ] Should make it wider
27− - [ ] Tab and ctrl+y should accept
28− - [ ] Words should line up
29− - [ ] If there are no completions and cick tab/ctrl+y/enter it should close it
30−- [ ] Investigate messages issues
31− - [ ] Make the agent separator look like the
32− - [ ] Cleanup tool calls (watch all states)
33− - [ ] Weird behavior sometimes the message does not update
34− - [ ] Message length (I saw the message go beyond the correct length when there are errors)
35− - [ ] Address UX issues
36− - [ ] Fix issue with numbers (padding) view tool
37−- [x] Implement responsive mode
38−- [ ] Update interactive mode to use the spinner
39−- [ ] Revisit the core list component
40− - [ ] This component has become super complex we might need to fix this.
41−- [ ] Handle correct LSP and MCP status icon
42−- [x] Investigate ways to make the spinner less CPU intensive
43−- [ ] General cleanup and documentation
44−- [ ] Update the readme
45−
46−## Maybe
47−
48−- [ ] Revisit the provider/model/configs
49−- [ ] Implement correct persistent shell
50−- [ ] Store file read/write time somewhere so that the we can make sure that even if we restart we do not need to re-read the same file
51−- [ ] Send updates to the UI when new LSP diagnostics are available