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"
"io"
"os"
"path/filepath"
"strings"
"time"
@ -116,15 +115,48 @@ func readPrompt() (string, error) {
return "", fmt.Errorf("no prompt provided (use positional arg, --prompt, or pipe to stdin)")
}
func loadEvalConfig() (*config.Config, error) {
evalConfig := os.Getenv("PICOCLAW_EVAL_CONFIG")
if evalConfig != "" {
return config.LoadConfig(evalConfig)
// 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"
}
home, _ := os.UserHomeDir()
configPath := filepath.Join(home, ".picoclaw", "config.json")
return config.LoadConfig(configPath)
func loadEvalConfig() (*config.Config, error) {
// Always load the user's base config first so provider API keys,
// model selection, and other credentials are preserved.
cfg, err := config.LoadConfig(resolveBaseConfigPath())
if err != nil {
return nil, fmt.Errorf("load base config: %w", err)
}
// If an eval-specific override file is set, merge it on top of the base
// config. Only fields present in the overlay are changed; API keys etc.
// 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 {

View file

@ -520,6 +520,27 @@ func (c *Config) Validate() []string {
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 {
cfg.mu.RLock()
defer cfg.mu.RUnlock()