refactor: changing api keys place to .env
This commit is contained in:
parent
0c5d7500e8
commit
2be28f1c21
6 changed files with 321 additions and 18 deletions
87
.env.example
87
.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
|
||||
|
|
|
|||
|
|
@ -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
1
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
|
||||
|
|
|
|||
2
go.sum
2
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=
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue