refactor: changing api keys place to .env

This commit is contained in:
ukpratik 2026-03-15 16:56:55 +05:30
parent 0c5d7500e8
commit 2be28f1c21
6 changed files with 321 additions and 18 deletions

View file

@ -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

View file

@ -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}"
}
}
}

1
go.mod
View file

@ -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

2
go.sum
View file

@ -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=

View file

@ -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)
}

View file

@ -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")
}
}