feat(config): add OverlayConfigFile for partial config merging

Add OverlayConfigFile(cfg *Config, path string) which reads a JSON file
and merges its fields into an existing Config using jsonv2.Unmarshal.
Fields present in the overlay overwrite the base; absent fields are left
unchanged. A non-existent overlay file is silently ignored.

Wire into the eval runner: loadEvalConfig() now always loads the user's
base config first (XDG path with legacy fallback) so provider API keys
and model selection are preserved, then applies PICOCLAW_EVAL_CONFIG as
an overlay if set. Removes the previous behaviour of replacing the entire
config with the eval file.
This commit is contained in:
ZanzyTHEbar 2026-02-19 21:25:25 +00:00
parent 215d8efcf7
commit 795dde9f44
2 changed files with 60 additions and 7 deletions

View file

@ -6,7 +6,6 @@ import (
"fmt" "fmt"
"io" "io"
"os" "os"
"path/filepath"
"strings" "strings"
"time" "time"
@ -116,15 +115,48 @@ func readPrompt() (string, error) {
return "", fmt.Errorf("no prompt provided (use positional arg, --prompt, or pipe to stdin)") return "", fmt.Errorf("no prompt provided (use positional arg, --prompt, or pipe to stdin)")
} }
// resolveBaseConfigPath returns the path to the user's config file, checking
// candidate paths in order and returning the first one that exists. Falls back
// to the XDG-standard path even if the file is absent, matching the main
// picoclaw binary's behaviour.
func resolveBaseConfigPath() string {
// Prefer XDG standard path (~/.config/picoclaw/config.json).
if xdgPath, err := config.DefaultConfigPath(); err == nil {
if _, statErr := os.Stat(xdgPath); statErr == nil {
return xdgPath
}
}
// Legacy path used before XDG migration.
home, _ := os.UserHomeDir()
legacy := home + "/.picoclaw/config.json"
if _, err := os.Stat(legacy); err == nil {
return legacy
}
// Neither exists; return the XDG path so LoadConfig returns defaults.
if xdgPath, err := config.DefaultConfigPath(); err == nil {
return xdgPath
}
return home + "/.picoclaw/config.json"
}
func loadEvalConfig() (*config.Config, error) { func loadEvalConfig() (*config.Config, error) {
evalConfig := os.Getenv("PICOCLAW_EVAL_CONFIG") // Always load the user's base config first so provider API keys,
if evalConfig != "" { // model selection, and other credentials are preserved.
return config.LoadConfig(evalConfig) cfg, err := config.LoadConfig(resolveBaseConfigPath())
if err != nil {
return nil, fmt.Errorf("load base config: %w", err)
} }
home, _ := os.UserHomeDir() // If an eval-specific override file is set, merge it on top of the base
configPath := filepath.Join(home, ".picoclaw", "config.json") // config. Only fields present in the overlay are changed; API keys etc.
return config.LoadConfig(configPath) // from the base config are preserved.
if overlayPath := os.Getenv("PICOCLAW_EVAL_CONFIG"); overlayPath != "" {
if err := config.OverlayConfigFile(cfg, overlayPath); err != nil {
return nil, fmt.Errorf("load eval overlay: %w", err)
}
}
return cfg, nil
} }
func runEval(cfg *config.Config, prompt string) Trace { func runEval(cfg *config.Config, prompt string) Trace {

View file

@ -520,6 +520,27 @@ func (c *Config) Validate() []string {
return warnings return warnings
} }
// OverlayConfigFile reads a JSON file and merges its fields into an existing
// Config. Fields present in the overlay file overwrite the corresponding fields
// in cfg; fields absent from the overlay file are left unchanged. This allows
// partial override files (e.g. eval configs that only set tools or agent
// settings) without losing base config values such as provider API keys.
//
// A non-existent overlay file is silently ignored.
func OverlayConfigFile(cfg *Config, path string) error {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("read overlay config %q: %w", path, err)
}
if err := jsonv2.Unmarshal(data, cfg); err != nil {
return fmt.Errorf("parse overlay config %q: %w", path, err)
}
return nil
}
func SaveConfig(path string, cfg *Config) error { func SaveConfig(path string, cfg *Config) error {
cfg.mu.RLock() cfg.mu.RLock()
defer cfg.mu.RUnlock() defer cfg.mu.RUnlock()