diff --git a/.env.example b/.env.example index 66010b1f5..38e4ac146 100644 --- a/.env.example +++ b/.env.example @@ -1,22 +1,83 @@ -# ── LLM Provider ────────────────────────── -# Uncomment and set the API key for your provider -# OPENROUTER_API_KEY=sk-or-v1-xxx -# ZHIPU_API_KEY=xxx -# ANTHROPIC_API_KEY=sk-ant-xxx +# PicoClaw environment variable configuration +# Copy this file to .env and fill in your credentials. +# Values set here are overridden by shell-exported variables. +# +# .env is loaded automatically from the current working directory. +# .env.example is safe to commit — .env is git-ignored. + +# ── LLM Providers ───────────────────────────────────────────── +# Set the API key for whichever provider(s) you use in model_list. +# These are applied to model_list entries whose api_key is empty. # OPENAI_API_KEY=sk-xxx +# ANTHROPIC_API_KEY=sk-ant-xxx +# OPENROUTER_API_KEY=sk-or-v1-xxx +# GROQ_API_KEY=gsk_xxx # GEMINI_API_KEY=xxx +# DEEPSEEK_API_KEY=sk-xxx +# ZHIPU_API_KEY=xxx +# MOONSHOT_API_KEY=sk-xxx +# MISTRAL_API_KEY=xxx +# VOLCENGINE_API_KEY=xxx # MODELSCOPE_API_KEY=xxx -# CLAUDE_CODE_OAUTH=xxx -# ── Chat Channel ────────────────────────── -# TELEGRAM_BOT_TOKEN=123456:ABC... -# DISCORD_BOT_TOKEN=xxx -# Feishu (飞书) +# LONGCAT_API_KEY=xxx +# NVIDIA_API_KEY=nvapi-xxx +# CEREBRAS_API_KEY=xxx +# PERPLEXITY_API_KEY=pplx-xxx +# AVIAN_API_KEY=xxx +# QWEN_API_KEY=sk-xxx +# OLLAMA_API_KEY=xxx + +# ── Channels ────────────────────────────────────────────────── +# PICOCLAW_CHANNELS_TELEGRAM_TOKEN=123456:ABC... +# PICOCLAW_CHANNELS_DISCORD_TOKEN=xxx +# PICOCLAW_CHANNELS_SLACK_BOT_TOKEN=xoxb-xxx +# PICOCLAW_CHANNELS_SLACK_APP_TOKEN=xapp-xxx # PICOCLAW_CHANNELS_FEISHU_APP_ID=cli_xxx # PICOCLAW_CHANNELS_FEISHU_APP_SECRET=xxx +# PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY=xxx +# PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN=xxx +# PICOCLAW_CHANNELS_QQ_APP_ID=xxx +# PICOCLAW_CHANNELS_QQ_APP_SECRET=xxx +# PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID=xxx +# PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET=xxx +# PICOCLAW_CHANNELS_MATRIX_ACCESS_TOKEN=xxx +# PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET=xxx +# PICOCLAW_CHANNELS_LINE_CHANNEL_ACCESS_TOKEN=xxx +# PICOCLAW_CHANNELS_ONEBOT_ACCESS_TOKEN=xxx +# PICOCLAW_CHANNELS_WECOM_TOKEN=xxx +# PICOCLAW_CHANNELS_WECOM_ENCODING_AES_KEY=xxx +# PICOCLAW_CHANNELS_WECOM_APP_CORP_ID=xxx +# PICOCLAW_CHANNELS_WECOM_APP_CORP_SECRET=xxx +# PICOCLAW_CHANNELS_WECOM_APP_TOKEN=xxx +# PICOCLAW_CHANNELS_WECOM_APP_ENCODING_AES_KEY=xxx +# PICOCLAW_CHANNELS_WECOM_AIBOT_TOKEN=xxx +# PICOCLAW_CHANNELS_WECOM_AIBOT_ENCODING_AES_KEY=xxx +# PICOCLAW_CHANNELS_IRC_PASSWORD=xxx +# PICOCLAW_CHANNELS_IRC_NICKSERV_PASSWORD=xxx +# PICOCLAW_CHANNELS_IRC_SASL_USER=xxx +# PICOCLAW_CHANNELS_IRC_SASL_PASSWORD=xxx # PICOCLAW_CHANNELS_FEISHU_RANDOM_REACTION_EMOJI=Typing,OneSecond -# ── Web Search (optional) ──────────────── -# BRAVE_SEARCH_API_KEY=BSA... +# ── Web Search ──────────────────────────────────────────────── +# PICOCLAW_TOOLS_WEB_BRAVE_API_KEY=BSA-xxx +# PICOCLAW_TOOLS_WEB_TAVILY_API_KEY=xxx +# PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEY=pplx-xxx +# PICOCLAW_TOOLS_WEB_GLM_API_KEY=xxx -# ── Timezone ────────────────────────────── +# ── Skills ──────────────────────────────────────────────────── +# PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_AUTH_TOKEN=xxx +# PICOCLAW_TOOLS_SKILLS_GITHUB_AUTH_TOKEN=xxx + +# ── MCP Servers ─────────────────────────────────────────────── +# Context7 (HTTP) +# CONTEXT7_API_KEY=ctx7sk-xxx +# GitHub MCP (stdio) +# GITHUB_PERSONAL_ACCESS_TOKEN=ghp_xxx +# Brave Search MCP (stdio) +# BRAVE_API_KEY=BSA-xxx +# Slack MCP (stdio) +# SLACK_BOT_TOKEN=xoxb-xxx +# SLACK_TEAM_ID=T0xxxxxxxxx + +# ── Timezone ────────────────────────────────────────────────── TZ=Asia/Shanghai diff --git a/config/config.example.json b/config/config.example.json index 094aa46df..094c48b17 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -366,7 +366,7 @@ "type": "http", "url": "https://mcp.context7.com/mcp", "headers": { - "CONTEXT7_API_KEY": "ctx7sk-xx" + "CONTEXT7_API_KEY": "${CONTEXT7_API_KEY}" } }, "filesystem": { @@ -386,7 +386,7 @@ "@modelcontextprotocol/server-github" ], "env": { - "GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_TOKEN" + "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_PERSONAL_ACCESS_TOKEN}" } }, "brave-search": { @@ -397,7 +397,7 @@ "@modelcontextprotocol/server-brave-search" ], "env": { - "BRAVE_API_KEY": "YOUR_BRAVE_API_KEY" + "BRAVE_API_KEY": "${BRAVE_API_KEY}" } }, "postgres": { @@ -417,8 +417,8 @@ "@modelcontextprotocol/server-slack" ], "env": { - "SLACK_BOT_TOKEN": "YOUR_SLACK_BOT_TOKEN", - "SLACK_TEAM_ID": "YOUR_SLACK_TEAM_ID" + "SLACK_BOT_TOKEN": "${SLACK_BOT_TOKEN}", + "SLACK_TEAM_ID": "${SLACK_TEAM_ID}" } } } diff --git a/go.mod b/go.mod index f29ef7207..7f9b08d6c 100644 --- a/go.mod +++ b/go.mod @@ -44,6 +44,7 @@ require ( github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect github.com/gdamore/encoding v1.0.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/joho/godotenv v1.5.1 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect diff --git a/go.sum b/go.sum index addbab56c..2c8d2a897 100644 --- a/go.sum +++ b/go.sum @@ -105,6 +105,8 @@ github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyf github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= diff --git a/pkg/config/config.go b/pkg/config/config.go index 190341224..f85323344 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -4,10 +4,12 @@ import ( "encoding/json" "fmt" "os" + "regexp" "strings" "sync/atomic" "github.com/caarlos0/env/v11" + "github.com/joho/godotenv" "github.com/sipeed/picoclaw/pkg/fileutil" ) @@ -837,10 +839,24 @@ func LoadConfig(path string) (*Config, error) { return nil, err } + // Load .env from current working directory before parsing env vars. + // Uses Load (not Overload) so shell-exported vars take precedence over .env file. + // Silently ignored if .env does not exist. + _ = godotenv.Load(".env") + if err := env.Parse(cfg); err != nil { return nil, err } + // Apply well-known provider env vars (e.g. OPENAI_API_KEY) to model_list entries + // whose api_key is empty. caarlos0/env cannot map slice elements, so this is done + // as a post-processing step. + applyModelEnvOverrides(cfg) + + // Resolve ${VAR} references in MCP server Env and Headers maps so secrets + // can live in .env instead of config.json. + applyMCPEnvOverrides(cfg) + // Migrate legacy channel config fields to new unified structures cfg.migrateChannelConfigs() @@ -880,6 +896,90 @@ func SaveConfig(path string, cfg *Config) error { return fileutil.WriteFileAtomic(path, data, 0o600) } +// applyModelEnvOverrides reads well-known provider API key env vars and applies +// them to model_list entries whose api_key field is empty. This handles the +// model_list slice case that caarlos0/env cannot map via struct tags. +// +// Precedence: config.json api_key > shell env var / .env file > nothing. +func applyModelEnvOverrides(cfg *Config) { + // Maps provider prefix (from "provider/model" format) to env var name. + providerEnvMap := map[string]string{ + "openai": "OPENAI_API_KEY", + "anthropic": "ANTHROPIC_API_KEY", + "anthropic-messages": "ANTHROPIC_API_KEY", + "openrouter": "OPENROUTER_API_KEY", + "groq": "GROQ_API_KEY", + "gemini": "GEMINI_API_KEY", + "google": "GEMINI_API_KEY", + "deepseek": "DEEPSEEK_API_KEY", + "zhipu": "ZHIPU_API_KEY", + "moonshot": "MOONSHOT_API_KEY", + "mistral": "MISTRAL_API_KEY", + "cerebras": "CEREBRAS_API_KEY", + "volcengine": "VOLCENGINE_API_KEY", + "modelscope": "MODELSCOPE_API_KEY", + "longcat": "LONGCAT_API_KEY", + "nvidia": "NVIDIA_API_KEY", + "perplexity": "PERPLEXITY_API_KEY", + "avian": "AVIAN_API_KEY", + "qwen-portal": "QWEN_API_KEY", + "ollama": "OLLAMA_API_KEY", + } + for i, m := range cfg.ModelList { + if m.APIKey != "" { + continue // already set in config.json, don't override + } + provider, _, ok := strings.Cut(m.Model, "/") + if !ok { + continue + } + envVar, ok := providerEnvMap[strings.ToLower(provider)] + if !ok { + continue + } + if val := os.Getenv(envVar); val != "" { + cfg.ModelList[i].APIKey = val + } + } +} + +// envVarRef matches a value that is entirely a ${VAR_NAME} reference. +var envVarRef = regexp.MustCompile(`^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$`) + +// resolveEnvRef resolves a ${VAR_NAME} reference to its env value. +// Returns the original value unchanged if it is not a reference or the var is unset. +func resolveEnvRef(value string) string { + if m := envVarRef.FindStringSubmatch(value); m != nil { + if resolved := os.Getenv(m[1]); resolved != "" { + return resolved + } + } + return value +} + +// applyMCPEnvOverrides resolves ${VAR} references in MCP server Env and Headers maps. +// This allows secrets to live in .env instead of config.json. +func applyMCPEnvOverrides(cfg *Config) { + for name, srv := range cfg.Tools.MCP.Servers { + changed := false + for k, v := range srv.Env { + if resolved := resolveEnvRef(v); resolved != v { + srv.Env[k] = resolved + changed = true + } + } + for k, v := range srv.Headers { + if resolved := resolveEnvRef(v); resolved != v { + srv.Headers[k] = resolved + changed = true + } + } + if changed { + cfg.Tools.MCP.Servers[name] = srv + } + } +} + func (c *Config) WorkspacePath() string { return expandHome(c.Agents.Defaults.Workspace) } diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index c5bdbf3c3..f234b03f7 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -621,3 +621,142 @@ func TestFlexibleStringSlice_UnmarshalText_EmptySliceConsistency(t *testing.T) { } }) } + +func TestResolveEnvRef(t *testing.T) { + t.Setenv("MY_SECRET", "resolved-value") + + tests := []struct { + input string + want string + }{ + {"${MY_SECRET}", "resolved-value"}, // set var → resolved + {"${UNSET_VAR_XYZ}", "${UNSET_VAR_XYZ}"}, // unset var → unchanged + {"literal-value", "literal-value"}, // no pattern → unchanged + {"prefix-${MY_SECRET}", "prefix-${MY_SECRET}"}, // partial → unchanged (no partial substitution) + {"${MY_SECRET}-suffix", "${MY_SECRET}-suffix"}, // partial → unchanged + {"${}", "${}"}, // empty name → unchanged + } + + for _, tc := range tests { + got := resolveEnvRef(tc.input) + if got != tc.want { + t.Errorf("resolveEnvRef(%q) = %q, want %q", tc.input, got, tc.want) + } + } +} + +func TestApplyMCPEnvOverrides_Headers(t *testing.T) { + t.Setenv("CONTEXT7_API_KEY", "ctx7sk-test") + + cfg := DefaultConfig() + cfg.Tools.MCP.Servers = map[string]MCPServerConfig{ + "context7": { + Headers: map[string]string{ + "CONTEXT7_API_KEY": "${CONTEXT7_API_KEY}", + "STATIC_HEADER": "keep-as-is", + }, + }, + } + + applyMCPEnvOverrides(cfg) + + srv := cfg.Tools.MCP.Servers["context7"] + if got := srv.Headers["CONTEXT7_API_KEY"]; got != "ctx7sk-test" { + t.Errorf("Headers[CONTEXT7_API_KEY] = %q, want %q", got, "ctx7sk-test") + } + if got := srv.Headers["STATIC_HEADER"]; got != "keep-as-is" { + t.Errorf("Headers[STATIC_HEADER] = %q, want %q", got, "keep-as-is") + } +} + +func TestApplyMCPEnvOverrides_Env(t *testing.T) { + t.Setenv("GITHUB_PERSONAL_ACCESS_TOKEN", "ghp-test-token") + + cfg := DefaultConfig() + cfg.Tools.MCP.Servers = map[string]MCPServerConfig{ + "github": { + Env: map[string]string{ + "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_PERSONAL_ACCESS_TOKEN}", + }, + }, + } + + applyMCPEnvOverrides(cfg) + + srv := cfg.Tools.MCP.Servers["github"] + if got := srv.Env["GITHUB_PERSONAL_ACCESS_TOKEN"]; got != "ghp-test-token" { + t.Errorf("Env[GITHUB_PERSONAL_ACCESS_TOKEN] = %q, want %q", got, "ghp-test-token") + } +} + +func TestApplyMCPEnvOverrides_BackwardCompat(t *testing.T) { + cfg := DefaultConfig() + cfg.Tools.MCP.Servers = map[string]MCPServerConfig{ + "server": { + Headers: map[string]string{"KEY": "hardcoded-value"}, + Env: map[string]string{"VAR": "hardcoded-env"}, + }, + } + + applyMCPEnvOverrides(cfg) + + srv := cfg.Tools.MCP.Servers["server"] + if got := srv.Headers["KEY"]; got != "hardcoded-value" { + t.Errorf("Headers[KEY] = %q, want %q (hardcoded value should pass through)", got, "hardcoded-value") + } + if got := srv.Env["VAR"]; got != "hardcoded-env" { + t.Errorf("Env[VAR] = %q, want %q (hardcoded value should pass through)", got, "hardcoded-env") + } +} + +func TestApplyMCPEnvOverrides_UnsetVarUnchanged(t *testing.T) { + os.Unsetenv("DEFINITELY_UNSET_VAR_12345") + + cfg := DefaultConfig() + cfg.Tools.MCP.Servers = map[string]MCPServerConfig{ + "server": { + Headers: map[string]string{"KEY": "${DEFINITELY_UNSET_VAR_12345}"}, + }, + } + + applyMCPEnvOverrides(cfg) + + srv := cfg.Tools.MCP.Servers["server"] + if got := srv.Headers["KEY"]; got != "${DEFINITELY_UNSET_VAR_12345}" { + t.Errorf("Headers[KEY] = %q, want original %q when var is unset", got, "${DEFINITELY_UNSET_VAR_12345}") + } +} + +func TestLoadConfig_MCPEnvOverrides(t *testing.T) { + t.Setenv("CONTEXT7_API_KEY", "ctx7sk-integration") + + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + configJSON := `{ + "tools": { + "mcp": { + "servers": { + "context7": { + "enabled": true, + "type": "http", + "url": "https://mcp.context7.com/mcp", + "headers": {"CONTEXT7_API_KEY": "${CONTEXT7_API_KEY}"} + } + } + } + } +}` + if err := os.WriteFile(configPath, []byte(configJSON), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + + srv := cfg.Tools.MCP.Servers["context7"] + if got := srv.Headers["CONTEXT7_API_KEY"]; got != "ctx7sk-integration" { + t.Errorf("Headers[CONTEXT7_API_KEY] = %q, want %q", got, "ctx7sk-integration") + } +}