From ce64b68f1268f16c503219558383614329dd36e0 Mon Sep 17 00:00:00 2001 From: Victor Vargas Date: Tue, 30 Jun 2026 23:53:20 -0700 Subject: [PATCH] feat(config): add YAML config loader with hierarchical precedence --- pkg/config/config.go | 70 +++++++++++++++++++++++++ pkg/config/config_test.go | 82 +++++++++++++++++++++++++++++ pkg/config/loader.go | 105 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 257 insertions(+) create mode 100644 pkg/config/config.go create mode 100644 pkg/config/config_test.go create mode 100644 pkg/config/loader.go diff --git a/pkg/config/config.go b/pkg/config/config.go new file mode 100644 index 0000000..a663a54 --- /dev/null +++ b/pkg/config/config.go @@ -0,0 +1,70 @@ +package config + +import ( + "context" + "fmt" +) + +// ProviderConfig holds provider-specific settings. +type ProviderConfig struct { + Type string `yaml:"type"` + Model string `yaml:"model"` + APIKey string `yaml:"api_key"` + BaseURL string `yaml:"base_url,omitempty"` + MaxTokens int `yaml:"max_tokens,omitempty"` + Temperature float32 `yaml:"temperature,omitempty"` +} + +// ToolPolicy controls which tools are available and their permissions. +type ToolPolicy struct { + DefaultPermission string `yaml:"default_permission"` + AllowList []string `yaml:"allow_list,omitempty"` + DenyList []string `yaml:"deny_list,omitempty"` +} + +// LoggingConfig controls logging output. +type LoggingConfig struct { + Level string `yaml:"level"` + Format string `yaml:"format"` + Output string `yaml:"output"` +} + +// Config is the top-level configuration for the agent. +type Config struct { + Model string `yaml:"model"` + Provider ProviderConfig `yaml:"provider"` + Tools ToolPolicy `yaml:"tools"` + Logging LoggingConfig `yaml:"logging"` +} + +// Loader is responsible for loading configuration from various sources. +type Loader interface { + Load(ctx context.Context, workdir string) (Config, error) +} + +// ErrConfigNotFound is returned when no configuration file is found. +var ErrConfigNotFound = fmt.Errorf("config file not found") + +// ErrInvalidConfig is returned when the configuration is invalid. +var ErrInvalidConfig = fmt.Errorf("invalid configuration") + +// LoadDefault returns a Config with sensible defaults. +func LoadDefault() Config { + return Config{ + Model: "gpt-4o", + Provider: ProviderConfig{ + Type: "openai", + Model: "gpt-4o", + Temperature: 0.7, + MaxTokens: 4096, + }, + Tools: ToolPolicy{ + DefaultPermission: "allow", + }, + Logging: LoggingConfig{ + Level: "info", + Format: "text", + Output: "stderr", + }, + } +} diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go new file mode 100644 index 0000000..f7878b1 --- /dev/null +++ b/pkg/config/config_test.go @@ -0,0 +1,82 @@ +package config + +import ( + "context" + "os" + "path/filepath" + "testing" +) + +func TestLoadDefault(t *testing.T) { + cfg := LoadDefault() + if cfg.Model != "gpt-4o" { + t.Errorf("expected default model 'gpt-4o', got %q", cfg.Model) + } + if cfg.Provider.Type != "openai" { + t.Errorf("expected default provider 'openai', got %q", cfg.Provider.Type) + } +} + +func TestYAMLLoader_NoFile(t *testing.T) { + loader := NewYAMLLoader() + ctx := context.Background() + cfg, err := loader.Load(ctx, "/tmp/nonexistent_workdir_12345") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.Model != "gpt-4o" { + t.Errorf("expected default model, got %q", cfg.Model) + } +} + +func TestYAMLLoader_WithFile(t *testing.T) { + tmpDir := t.TempDir() + yaml := ` +provider: + type: anthropic + model: claude-sonnet-4.5 + api_key: test-key +` + if err := os.WriteFile(filepath.Join(tmpDir, "rony.yaml"), []byte(yaml), 0644); err != nil { + t.Fatal(err) + } + + loader := NewYAMLLoader() + ctx := context.Background() + cfg, err := loader.Load(ctx, tmpDir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.Provider.Type != "anthropic" { + t.Errorf("expected provider 'anthropic', got %q", cfg.Provider.Type) + } + if cfg.Provider.Model != "claude-sonnet-4.5" { + t.Errorf("expected model 'claude-sonnet-4.5', got %q", cfg.Provider.Model) + } + if cfg.Provider.APIKey != "test-key" { + t.Errorf("expected api_key 'test-key', got %q", cfg.Provider.APIKey) + } +} + +func TestYAMLLoader_PreservesDefaults(t *testing.T) { + tmpDir := t.TempDir() + yaml := ` +provider: + type: openai + model: gpt-4o-mini +` + if err := os.WriteFile(filepath.Join(tmpDir, "rony.yaml"), []byte(yaml), 0644); err != nil { + t.Fatal(err) + } + + loader := NewYAMLLoader() + ctx := context.Background() + cfg, err := loader.Load(ctx, tmpDir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Should still have default logging config + if cfg.Logging.Level != "info" { + t.Errorf("expected default logging level 'info', got %q", cfg.Logging.Level) + } +} diff --git a/pkg/config/loader.go b/pkg/config/loader.go new file mode 100644 index 0000000..d0f2e2f --- /dev/null +++ b/pkg/config/loader.go @@ -0,0 +1,105 @@ +package config + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +// yamlLoader loads config from YAML files with hierarchical precedence. +type yamlLoader struct{} + +// NewYAMLLoader returns a Loader that reads from YAML files. +func NewYAMLLoader() Loader { + return &yamlLoader{} +} + +func (l *yamlLoader) Load(ctx context.Context, workdir string) (Config, error) { + defaults := LoadDefault() + + // Load from workdir + path := filepath.Join(workdir, "rony.yaml") + if _, err := os.Stat(path); err == nil { + cfg, err := loadFromFile(path) + if err != nil { + return Config{}, fmt.Errorf("loading %s: %w", path, err) + } + // Merge with defaults + cfg = merge(defaults, cfg) + return cfg, nil + } + + // Load from home directory + home, err := os.UserHomeDir() + if err == nil { + homePath := filepath.Join(home, ".config", "rony", "config.yaml") + if _, err := os.Stat(homePath); err == nil { + cfg, err := loadFromFile(homePath) + if err == nil { + return merge(defaults, cfg), nil + } + } + } + + return defaults, nil +} + +func loadFromFile(path string) (Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return Config{}, err + } + + var cfg Config + if err := yaml.Unmarshal(data, &cfg); err != nil { + return Config{}, fmt.Errorf("parsing YAML: %w", err) + } + return cfg, nil +} + +// merge combines two configs, with b taking precedence over a. +func merge(a, b Config) Config { + if b.Model != "" { + a.Model = b.Model + } + if b.Provider.Type != "" { + a.Provider.Type = b.Provider.Type + } + if b.Provider.Model != "" { + a.Provider.Model = b.Provider.Model + } + if b.Provider.APIKey != "" { + a.Provider.APIKey = b.Provider.APIKey + } + if b.Provider.BaseURL != "" { + a.Provider.BaseURL = b.Provider.BaseURL + } + if b.Provider.MaxTokens > 0 { + a.Provider.MaxTokens = b.Provider.MaxTokens + } + if b.Provider.Temperature > 0 { + a.Provider.Temperature = b.Provider.Temperature + } + if b.Tools.DefaultPermission != "" { + a.Tools.DefaultPermission = b.Tools.DefaultPermission + } + if len(b.Tools.AllowList) > 0 { + a.Tools.AllowList = b.Tools.AllowList + } + if len(b.Tools.DenyList) > 0 { + a.Tools.DenyList = b.Tools.DenyList + } + if b.Logging.Level != "" { + a.Logging.Level = b.Logging.Level + } + if b.Logging.Format != "" { + a.Logging.Format = b.Logging.Format + } + if b.Logging.Output != "" { + a.Logging.Output = b.Logging.Output + } + return a +}