From e3981faff3f5b7d09a0761ba6bbe0c1b0412a5a5 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Fri, 3 Apr 2026 13:51:03 +0200 Subject: [PATCH] feat: add TOML config system with layered loading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layers: defaults → ~/.config/gnoma/config.toml → .gnoma/config.toml → environment variables. Supports ${VAR} references in API keys, GNOMA_PROVIDER/GNOMA_MODEL env overrides, alternative env var names (ANTHROPICS_API_KEY, GOOGLE_API_KEY). Custom Duration type for TOML string parsing. 6 tests. --- go.mod | 1 + go.sum | 1 + internal/config/config.go | 39 ++++++++ internal/config/config_test.go | 165 +++++++++++++++++++++++++++++++++ internal/config/defaults.go | 19 ++++ internal/config/load.go | 105 +++++++++++++++++++++ 6 files changed, 330 insertions(+) create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/config/defaults.go create mode 100644 internal/config/load.go diff --git a/go.mod b/go.mod index 7d1d4f9..de2fe5a 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module somegit.dev/Owlibou/gnoma go 1.26.1 require ( + github.com/BurntSushi/toml v0.3.1 github.com/VikingOwl91/mistral-go-sdk v1.2.1 github.com/anthropics/anthropic-sdk-go v1.29.0 github.com/openai/openai-go v1.12.0 diff --git a/go.sum b/go.sum index 0667ca0..6d5877f 100644 --- a/go.sum +++ b/go.sum @@ -5,6 +5,7 @@ cloud.google.com/go/auth v0.9.3 h1:VOEUIAADkkLtyfr3BLa3R8Ed/j6w1jTBmARx+wb5w5U= cloud.google.com/go/auth v0.9.3/go.mod h1:7z6VY+7h3KUdRov5F1i8NDP5ZzWKYmEPO842BgCsmTk= cloud.google.com/go/compute/metadata v0.5.0 h1:Zr0eK8JbFv6+Wi4ilXAR8FJ3wyNdpxHKJNPos6LTZOY= cloud.google.com/go/compute/metadata v0.5.0/go.mod h1:aHnloV2TPI38yx4s9+wAZhHykWvVCfu7hQbF+9CWoiY= +github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/VikingOwl91/mistral-go-sdk v1.2.1 h1:6OQMtOzJUFcvFUEtbX9VlglUPBn+dKOrQPnyoVKlpkA= github.com/VikingOwl91/mistral-go-sdk v1.2.1/go.mod h1:f4emNtHUx2zSqY3V0LBz6lNI1jE6q/zh+SEU+/hJ0i4= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..426d453 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,39 @@ +package config + +import "time" + +// Config is the top-level configuration. +type Config struct { + Provider ProviderSection `toml:"provider"` + Tools ToolsSection `toml:"tools"` +} + +type ProviderSection struct { + Default string `toml:"default"` + Model string `toml:"model"` + MaxTokens int64 `toml:"max_tokens"` + Temperature *float64 `toml:"temperature"` + APIKeys map[string]string `toml:"api_keys"` + Endpoints map[string]string `toml:"endpoints"` +} + +type ToolsSection struct { + BashTimeout Duration `toml:"bash_timeout"` + MaxFileSize int64 `toml:"max_file_size"` +} + +// Duration wraps time.Duration for TOML string parsing (e.g. "30s", "5m"). +type Duration time.Duration + +func (d *Duration) UnmarshalText(text []byte) error { + parsed, err := time.ParseDuration(string(text)) + if err != nil { + return err + } + *d = Duration(parsed) + return nil +} + +func (d Duration) Duration() time.Duration { + return time.Duration(d) +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..8066e72 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,165 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestDefaults(t *testing.T) { + cfg := Defaults() + if cfg.Provider.Default != "mistral" { + t.Errorf("Provider.Default = %q, want mistral", cfg.Provider.Default) + } + if cfg.Provider.MaxTokens != 8192 { + t.Errorf("Provider.MaxTokens = %d", cfg.Provider.MaxTokens) + } + if cfg.Tools.BashTimeout.Duration() != 30*time.Second { + t.Errorf("Tools.BashTimeout = %v", cfg.Tools.BashTimeout) + } +} + +func TestLoadTOML(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.toml") + + content := ` +[provider] +default = "anthropic" +model = "claude-sonnet-4" +max_tokens = 16384 + +[provider.api_keys] +anthropic = "sk-test-123" + +[provider.endpoints] +ollama = "http://myhost:11434/v1" + +[tools] +bash_timeout = "60s" +max_file_size = 2097152 +` + os.WriteFile(path, []byte(content), 0o644) + + cfg := Defaults() + if err := loadTOML(&cfg, path); err != nil { + t.Fatalf("loadTOML: %v", err) + } + + if cfg.Provider.Default != "anthropic" { + t.Errorf("Provider.Default = %q", cfg.Provider.Default) + } + if cfg.Provider.Model != "claude-sonnet-4" { + t.Errorf("Provider.Model = %q", cfg.Provider.Model) + } + if cfg.Provider.MaxTokens != 16384 { + t.Errorf("Provider.MaxTokens = %d", cfg.Provider.MaxTokens) + } + if cfg.Provider.APIKeys["anthropic"] != "sk-test-123" { + t.Errorf("APIKeys[anthropic] = %q", cfg.Provider.APIKeys["anthropic"]) + } + if cfg.Provider.Endpoints["ollama"] != "http://myhost:11434/v1" { + t.Errorf("Endpoints[ollama] = %q", cfg.Provider.Endpoints["ollama"]) + } + if cfg.Tools.BashTimeout.Duration() != 60*time.Second { + t.Errorf("Tools.BashTimeout = %v", cfg.Tools.BashTimeout) + } + if cfg.Tools.MaxFileSize != 2097152 { + t.Errorf("Tools.MaxFileSize = %d", cfg.Tools.MaxFileSize) + } +} + +func TestLoadTOML_FileNotFound(t *testing.T) { + cfg := Defaults() + err := loadTOML(&cfg, "/nonexistent/config.toml") + if !os.IsNotExist(err) { + t.Errorf("expected os.IsNotExist, got %v", err) + } +} + +func TestApplyEnv(t *testing.T) { + cfg := Defaults() + + t.Setenv("MISTRAL_API_KEY", "sk-mistral-test") + t.Setenv("ANTHROPICS_API_KEY", "sk-anthro-test") + t.Setenv("GOOGLE_API_KEY", "goog-test") + t.Setenv("GNOMA_PROVIDER", "openai") + t.Setenv("GNOMA_MODEL", "gpt-4o-mini") + + applyEnv(&cfg) + + if cfg.Provider.APIKeys["mistral"] != "sk-mistral-test" { + t.Errorf("APIKeys[mistral] = %q", cfg.Provider.APIKeys["mistral"]) + } + if cfg.Provider.APIKeys["anthropic"] != "sk-anthro-test" { + t.Errorf("APIKeys[anthropic] = %q (should pick ANTHROPICS_API_KEY)", cfg.Provider.APIKeys["anthropic"]) + } + if cfg.Provider.APIKeys["google"] != "goog-test" { + t.Errorf("APIKeys[google] = %q (should pick GOOGLE_API_KEY)", cfg.Provider.APIKeys["google"]) + } + if cfg.Provider.Default != "openai" { + t.Errorf("Provider.Default = %q, want openai (from GNOMA_PROVIDER)", cfg.Provider.Default) + } + if cfg.Provider.Model != "gpt-4o-mini" { + t.Errorf("Provider.Model = %q, want gpt-4o-mini (from GNOMA_MODEL)", cfg.Provider.Model) + } +} + +func TestApplyEnv_EnvVarReference(t *testing.T) { + cfg := Defaults() + cfg.Provider.APIKeys["custom"] = "${MY_CUSTOM_KEY}" + + t.Setenv("MY_CUSTOM_KEY", "resolved-value") + + applyEnv(&cfg) + + if cfg.Provider.APIKeys["custom"] != "resolved-value" { + t.Errorf("APIKeys[custom] = %q, want resolved-value", cfg.Provider.APIKeys["custom"]) + } +} + +func TestLayeredLoad(t *testing.T) { + // Set up global config + globalDir := t.TempDir() + gnomaDir := filepath.Join(globalDir, "gnoma") + os.MkdirAll(gnomaDir, 0o755) + os.WriteFile(filepath.Join(gnomaDir, "config.toml"), []byte(` +[provider] +default = "anthropic" +max_tokens = 4096 +`), 0o644) + + // Set up project config that overrides + projectDir := t.TempDir() + pGnomaDir := filepath.Join(projectDir, ".gnoma") + os.MkdirAll(pGnomaDir, 0o755) + os.WriteFile(filepath.Join(pGnomaDir, "config.toml"), []byte(` +[provider] +model = "claude-haiku" +`), 0o644) + + // Override XDG_CONFIG_HOME and working directory + t.Setenv("XDG_CONFIG_HOME", globalDir) + origDir, _ := os.Getwd() + os.Chdir(projectDir) + defer os.Chdir(origDir) + + cfg, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + + // Global: default = anthropic + if cfg.Provider.Default != "anthropic" { + t.Errorf("Default = %q, want anthropic (from global)", cfg.Provider.Default) + } + // Project: model = claude-haiku + if cfg.Provider.Model != "claude-haiku" { + t.Errorf("Model = %q, want claude-haiku (from project)", cfg.Provider.Model) + } + // Global: max_tokens = 4096 + if cfg.Provider.MaxTokens != 4096 { + t.Errorf("MaxTokens = %d, want 4096 (from global)", cfg.Provider.MaxTokens) + } +} diff --git a/internal/config/defaults.go b/internal/config/defaults.go new file mode 100644 index 0000000..88db89c --- /dev/null +++ b/internal/config/defaults.go @@ -0,0 +1,19 @@ +package config + +import "time" + +func Defaults() Config { + return Config{ + Provider: ProviderSection{ + Default: "mistral", + Model: "", + MaxTokens: 8192, + APIKeys: make(map[string]string), + Endpoints: make(map[string]string), + }, + Tools: ToolsSection{ + BashTimeout: Duration(30 * time.Second), + MaxFileSize: 1 << 20, // 1MB + }, + } +} diff --git a/internal/config/load.go b/internal/config/load.go new file mode 100644 index 0000000..dff75c4 --- /dev/null +++ b/internal/config/load.go @@ -0,0 +1,105 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/BurntSushi/toml" +) + +// Load reads and merges config from all layers. +// Order (lowest to highest priority): +// 1. Defaults +// 2. Global config: ~/.config/gnoma/config.toml +// 3. Project config: .gnoma/config.toml +// 4. Environment variables +func Load() (*Config, error) { + cfg := Defaults() + + // Layer 1: Global config + globalPath := globalConfigPath() + if err := loadTOML(&cfg, globalPath); err != nil && !os.IsNotExist(err) { + return nil, fmt.Errorf("loading global config %s: %w", globalPath, err) + } + + // Layer 2: Project config + projectPath := projectConfigPath() + if err := loadTOML(&cfg, projectPath); err != nil && !os.IsNotExist(err) { + return nil, fmt.Errorf("loading project config %s: %w", projectPath, err) + } + + // Layer 3: Environment variables + applyEnv(&cfg) + + return &cfg, nil +} + +func loadTOML(cfg *Config, path string) error { + data, err := os.ReadFile(path) + if err != nil { + return err + } + _, err = toml.Decode(string(data), cfg) + return err +} + +func globalConfigPath() string { + // XDG_CONFIG_HOME or ~/.config + configDir := os.Getenv("XDG_CONFIG_HOME") + if configDir == "" { + home, _ := os.UserHomeDir() + configDir = filepath.Join(home, ".config") + } + return filepath.Join(configDir, "gnoma", "config.toml") +} + +func projectConfigPath() string { + return filepath.Join(".gnoma", "config.toml") +} + +func applyEnv(cfg *Config) { + envKeys := map[string]string{ + "mistral": "MISTRAL_API_KEY", + "anthropic": "ANTHROPIC_API_KEY", + "openai": "OPENAI_API_KEY", + "google": "GEMINI_API_KEY", + } + // Also check alternative names + altKeys := map[string][]string{ + "anthropic": {"ANTHROPICS_API_KEY"}, + "google": {"GOOGLE_API_KEY"}, + } + + for provider, envVar := range envKeys { + if key := os.Getenv(envVar); key != "" { + cfg.Provider.APIKeys[provider] = key + continue + } + for _, alt := range altKeys[provider] { + if key := os.Getenv(alt); key != "" { + cfg.Provider.APIKeys[provider] = key + break + } + } + } + + // Resolve ${VAR} references in configured API keys + for k, v := range cfg.Provider.APIKeys { + if strings.HasPrefix(v, "${") && strings.HasSuffix(v, "}") { + envName := v[2 : len(v)-1] + if resolved := os.Getenv(envName); resolved != "" { + cfg.Provider.APIKeys[k] = resolved + } + } + } + + // Provider override + if p := os.Getenv("GNOMA_PROVIDER"); p != "" { + cfg.Provider.Default = p + } + if m := os.Getenv("GNOMA_MODEL"); m != "" { + cfg.Provider.Model = m + } +}