// Package config loads workflow tool configuration from environment variables // and/or a KEY=VALUE config file (`.env` format). // // # Load order (highest priority first) // // 1. Environment variables (always override everything else) // 2. Config file — path resolution: // a. Explicit path passed to Load() // b. WORKFLOW_CONFIG environment variable // c. .env in the current working directory // d. .workflow.env in the current working directory // // # Config file format // // Plain KEY=VALUE pairs, one per line. Lines starting with # are comments. // // # .env // LLM_URL=http://localhost:4000 // LLM_KEY=sk-xxx // LLM_MODEL=gpt-4o // WORKSPACE_ROOT=./workspace // PORT=8080 // // # v3.16+: Multi-provider configuration // OPENAI_API_KEY=sk-openai-xxx // OPENAI_MODEL=gpt-4.1 // ANTHROPIC_API_KEY=sk-ant-xxx // ANTHROPIC_MODEL=claude-sonnet-4-5-20250929 // // # For new users // // Copy .env.example to .env and fill in your credentials. // Never commit .env to version control. package config import ( "bufio" "os" "strings" ) // Config holds all runtime configuration for the workflow tools. type Config struct { LLM LLMConfig Providers map[string]ProviderConfig // v3.16+: per-provider settings keyed by provider name ("openai", "anthropic") Workspace WorkspaceConfig Server ServerConfig } // LLMConfig holds global/default LLM endpoint settings (backward-compatible with pre-3.16). type LLMConfig struct { URL string // LLM_URL (default: http://localhost:4000) Key string // LLM_KEY (fallback key if provider-specific key not set) Model string // LLM_MODEL (default: gpt-4o; v3.16+: supports "provider/modelId" format) } // ProviderConfig holds per-provider LLM settings (v3.16+). type ProviderConfig struct { APIKey string // e.g. OPENAI_API_KEY, ANTHROPIC_API_KEY Model string // e.g. OPENAI_MODEL, ANTHROPIC_MODEL (default modelId for this provider) BaseURL string // e.g. OPENAI_BASE_URL (optional URL override) } // WorkspaceConfig holds local file workspace settings. type WorkspaceConfig struct { Root string // WORKSPACE_ROOT (default: ./workspace) } // ServerConfig holds HTTP server settings. type ServerConfig struct { Port string // PORT (default: 8080) } // defaults returns a Config pre-populated with all default values. func defaults() Config { return Config{ LLM: LLMConfig{ URL: "http://localhost:4000", Model: "gpt-4o", }, Providers: make(map[string]ProviderConfig), Workspace: WorkspaceConfig{ Root: "./workspace", }, Server: ServerConfig{ Port: "8080", }, } } // Load reads configuration using the priority chain described in the package doc. // Pass an empty string for configFile to use automatic file discovery. func Load(configFile string) Config { cfg := defaults() // Resolve config file path path := resolveFilePath(configFile) // 1. Apply file values (lowest priority) if path != "" { applyFile(&cfg, path) } // 2. Apply environment variables (highest priority — always win) applyEnv(&cfg) return cfg } // resolveFilePath returns the config file path to use, or "" if none found. func resolveFilePath(explicit string) string { // a. Explicit argument if explicit != "" { return explicit } // b. WORKFLOW_CONFIG env var if v := os.Getenv("WORKFLOW_CONFIG"); v != "" { return v } // c. .env in CWD if _, err := os.Stat(".env"); err == nil { return ".env" } // d. .workflow.env in CWD if _, err := os.Stat(".workflow.env"); err == nil { return ".workflow.env" } return "" } // applyFile reads KEY=VALUE pairs from path and applies them to cfg. // Missing file or unrecognised keys are silently ignored. func applyFile(cfg *Config, path string) { f, err := os.Open(path) if err != nil { return } defer f.Close() kv := parseKV(bufio.NewScanner(f)) applyKV(cfg, kv) } // applyEnv reads relevant environment variables and applies them to cfg. func applyEnv(cfg *Config) { kv := make(map[string]string) for _, key := range []string{ "LLM_URL", "LLM_KEY", "LLM_MODEL", "OPENAI_API_KEY", "OPENAI_MODEL", "OPENAI_BASE_URL", "ANTHROPIC_API_KEY", "ANTHROPIC_MODEL", "ANTHROPIC_BASE_URL", "WORKSPACE_ROOT", "PORT", } { if v := os.Getenv(key); v != "" { kv[key] = v } } applyKV(cfg, kv) } // applyKV maps a flat key→value map onto cfg fields. func applyKV(cfg *Config, kv map[string]string) { // Global LLM settings (backward-compatible) if v, ok := kv["LLM_URL"]; ok && v != "" { cfg.LLM.URL = v } if v, ok := kv["LLM_KEY"]; ok { cfg.LLM.Key = v // allow empty string (clears previous value) } if v, ok := kv["LLM_MODEL"]; ok && v != "" { cfg.LLM.Model = v } // v3.16+: Per-provider settings if cfg.Providers == nil { cfg.Providers = make(map[string]ProviderConfig) } applyProviderKV(cfg, "openai", kv, "OPENAI_API_KEY", "OPENAI_MODEL", "OPENAI_BASE_URL") applyProviderKV(cfg, "anthropic", kv, "ANTHROPIC_API_KEY", "ANTHROPIC_MODEL", "ANTHROPIC_BASE_URL") // Workspace and server settings if v, ok := kv["WORKSPACE_ROOT"]; ok && v != "" { cfg.Workspace.Root = v } if v, ok := kv["PORT"]; ok && v != "" { cfg.Server.Port = v } } // applyProviderKV applies provider-level config from the kv map. func applyProviderKV(cfg *Config, name string, kv map[string]string, keyEnv, modelEnv, urlEnv string) { p := cfg.Providers[name] if v, ok := kv[keyEnv]; ok && v != "" { p.APIKey = v } if v, ok := kv[modelEnv]; ok && v != "" { p.Model = v } if v, ok := kv[urlEnv]; ok && v != "" { p.BaseURL = v } if p.APIKey != "" || p.Model != "" || p.BaseURL != "" { cfg.Providers[name] = p } } // parseKV scans lines from s, returning a KEY→value map. // Lines that are blank or start with # are ignored. // Lines without '=' are ignored. func parseKV(s *bufio.Scanner) map[string]string { m := make(map[string]string) for s.Scan() { line := strings.TrimSpace(s.Text()) if line == "" || strings.HasPrefix(line, "#") { continue } k, v, ok := strings.Cut(line, "=") if !ok { continue } k = strings.TrimSpace(k) v = strings.TrimSpace(v) // Strip optional surrounding quotes (" or ') if len(v) >= 2 && ((v[0] == '"' && v[len(v)-1] == '"') || (v[0] == '\'' && v[len(v)-1] == '\'')) { v = v[1 : len(v)-1] } m[k] = v } return m } // MaskKey returns a masked representation of an API key for logging. func MaskKey(key string) string { switch { case key == "": return "(not set)" case len(key) <= 8: return strings.Repeat("*", len(key)) default: return key[:4] + strings.Repeat("*", len(key)-8) + key[len(key)-4:] } }