feat: add TOML config system with layered loading
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.
This commit is contained in:
1
go.mod
1
go.mod
@@ -3,6 +3,7 @@ module somegit.dev/Owlibou/gnoma
|
|||||||
go 1.26.1
|
go 1.26.1
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/BurntSushi/toml v0.3.1
|
||||||
github.com/VikingOwl91/mistral-go-sdk v1.2.1
|
github.com/VikingOwl91/mistral-go-sdk v1.2.1
|
||||||
github.com/anthropics/anthropic-sdk-go v1.29.0
|
github.com/anthropics/anthropic-sdk-go v1.29.0
|
||||||
github.com/openai/openai-go v1.12.0
|
github.com/openai/openai-go v1.12.0
|
||||||
|
|||||||
1
go.sum
1
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/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 h1:Zr0eK8JbFv6+Wi4ilXAR8FJ3wyNdpxHKJNPos6LTZOY=
|
||||||
cloud.google.com/go/compute/metadata v0.5.0/go.mod h1:aHnloV2TPI38yx4s9+wAZhHykWvVCfu7hQbF+9CWoiY=
|
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/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 h1:6OQMtOzJUFcvFUEtbX9VlglUPBn+dKOrQPnyoVKlpkA=
|
||||||
github.com/VikingOwl91/mistral-go-sdk v1.2.1/go.mod h1:f4emNtHUx2zSqY3V0LBz6lNI1jE6q/zh+SEU+/hJ0i4=
|
github.com/VikingOwl91/mistral-go-sdk v1.2.1/go.mod h1:f4emNtHUx2zSqY3V0LBz6lNI1jE6q/zh+SEU+/hJ0i4=
|
||||||
|
|||||||
39
internal/config/config.go
Normal file
39
internal/config/config.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
165
internal/config/config_test.go
Normal file
165
internal/config/config_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
19
internal/config/defaults.go
Normal file
19
internal/config/defaults.go
Normal file
@@ -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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
105
internal/config/load.go
Normal file
105
internal/config/load.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user