diff --git a/README.ja.md b/README.ja.md index 7da16565f..5845aeb7c 100644 --- a/README.ja.md +++ b/README.ja.md @@ -128,7 +128,7 @@ cd picoclaw # 2. API キーを設定 cp config/config.example.json config/config.json -vim config/config.json # DISCORD_BOT_TOKEN, プロバイダーの API キーを設定 +vim config/config.json # または config.yaml を使用(DISCORD_BOT_TOKEN、プロバイダーの API キーを設定) # 3. ビルドと起動 docker compose --profile gateway up -d @@ -160,7 +160,8 @@ docker compose --profile gateway up -d ### 🚀 クイックスタート(ネイティブ) > [!TIP] -> `~/.picoclaw/config.json` に API キーを設定してください。 +> `~/.picoclaw/config.json`、`~/.picoclaw/config.yaml`、`~/.picoclaw/config.toml`、または `~/.picoclaw/config.ini` に API キーを設定してください。 +> PicoClaw は複数の設定フォーマットをサポート:**JSON**(デフォルト)、**YAML**、**TOML**、**INI** ([spf13/viper](https://github.com/spf13/viper) 経由)。 > API キーの取得先: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM) > Web 検索は **任意** です - 無料の [Brave Search API](https://brave.com/search/api) (月 2000 クエリ無料) @@ -170,7 +171,13 @@ docker compose --profile gateway up -d picoclaw onboard ``` -**2. 設定** (`~/.picoclaw/config.json`) +**2. 設定** (`~/.picoclaw/config.json`、`~/.picoclaw/config.yaml`、`~/.picoclaw/config.toml`、または `~/.picoclaw/config.ini`) + +[spf13/viper](https://github.com/spf13/viper) 経由で複数の設定フォーマットをサポート。以下のいずれかを選択: +- `config.json` - JSON フォーマット(デフォルト) +- `config.yaml` または `config.yml` - YAML フォーマット +- `config.toml` - TOML フォーマット +- `config.ini` - INI フォーマット ```json { diff --git a/README.md b/README.md index d6a3d5696..f129c14e7 100644 --- a/README.md +++ b/README.md @@ -160,7 +160,7 @@ cd picoclaw # 2. Set your API keys cp config/config.example.json config/config.json -vim config/config.json # Set DISCORD_BOT_TOKEN, API keys, etc. +vim config/config.json # Set DISCORD_BOT_TOKEN, API keys, etc. (or use config.yaml/config.toml/config.ini) # 3. Build & Start docker compose --profile gateway up -d @@ -192,7 +192,8 @@ docker compose --profile gateway up -d ### 🚀 Quick Start > [!TIP] -> Set your API key in `~/.picoclaw/config.json`. +> Set your API key in `~/.picoclaw/config.json`, `~/.picoclaw/config.yaml`, `~/.picoclaw/config.toml`, or `~/.picoclaw/config.ini`. +> PicoClaw supports multiple configuration formats: **JSON** (default), **YAML**, **TOML**, **INI** via [spf13/viper](https://github.com/spf13/viper). > Get API keys: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM) > Web search is **optional** - get free [Brave Search API](https://brave.com/search/api) (2000 free queries/month) or use built-in auto fallback. @@ -202,7 +203,13 @@ docker compose --profile gateway up -d picoclaw onboard ``` -**2. Configure** (`~/.picoclaw/config.json`) +# 2. Configure (`~/.picoclaw/config.json`, `~/.picoclaw/config.yaml`, `~/.picoclaw/config.toml`, or `~/.picoclaw/config.ini`) + +PicoClaw supports multiple configuration formats via [spf13/viper](https://github.com/spf13/viper). Choose any one: +- `config.json` - JSON format (default, most common) +- `config.yaml` or `config.yml` - YAML format +- `config.toml` - TOML format +- `config.ini` - INI format ```json { diff --git a/README.pt-br.md b/README.pt-br.md index fa73465dd..26f78c3d0 100644 --- a/README.pt-br.md +++ b/README.pt-br.md @@ -167,7 +167,7 @@ cd picoclaw # 2. Configure suas API keys cp config/config.example.json config/config.json -vim config/config.json # Configure DISCORD_BOT_TOKEN, API keys, etc. +vim config/config.json # Configure DISCORD_BOT_TOKEN, API keys, etc. (ou use config.yaml/config.yaml/config.toml/config.ini) # 3. Build & Iniciar docker compose --profile gateway up -d @@ -199,7 +199,8 @@ docker compose --profile gateway up -d ### 🚀 Início Rápido > [!TIP] -> Configure sua API key em `~/.picoclaw/config.json`. +> Configure sua API key em `~/.picoclaw/config.json`, `~/.picoclaw/config.yaml`, `~/.picoclaw/config.toml` ou `~/.picoclaw/config.ini`. +> PicoClaw suporta múltiplos formatos de configuração: **JSON** (padrão), **YAML**, **TOML**, **INI** via [spf13/viper](https://github.com/spf13/viper). > Obtenha API keys: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM) > Busca web e **opcional** — obtenha a [Brave Search API](https://brave.com/search/api) gratuita (2000 consultas grátis/mês) ou use o fallback automático integrado. @@ -209,7 +210,13 @@ docker compose --profile gateway up -d picoclaw onboard ``` -**2. Configurar** (`~/.picoclaw/config.json`) +**2. Configurar** (`~/.picoclaw/config.json`, `~/.picoclaw/config.yaml`, `~/.picoclaw/config.toml` ou `~/.picoclaw/config.ini`) + +PicoClaw suporta múltiplos formatos via [spf13/viper](https://github.com/spf13/viper). Escolha qualquer um: +- `config.json` - Formato JSON (padrão) +- `config.yaml` ou `config.yml` - Formato YAML +- `config.toml` - Formato TOML +- `config.ini` - Formato INI ```json { diff --git a/README.vi.md b/README.vi.md index e629eaa9b..a6bf4d596 100644 --- a/README.vi.md +++ b/README.vi.md @@ -145,9 +145,9 @@ Bạn cũng có thể chạy PicoClaw bằng Docker Compose mà không cần cà git clone https://github.com/sipeed/picoclaw.git cd picoclaw -# 2. Thiết lập API Key +# 2. Thiết lập API key cp config/config.example.json config/config.json -vim config/config.json # Thiết lập DISCORD_BOT_TOKEN, API keys, v.v. +vim config/config.json # Thiết lập DISCORD_BOT_TOKEN, API keys, v.v. (hoặc dùng config.yaml/config.toml/config.ini) # 3. Build & Khởi động docker compose --profile gateway up -d @@ -178,8 +178,13 @@ docker compose --profile gateway up -d ### 🚀 Bắt đầu nhanh +### Cấu hình + +Bạn có thể cấu hình PicoClaw bằng cách chỉnh sửa file cấu hình ở các định dạng sau: `config.json`, `config.yaml`, `config.toml`, hoặc `config.ini`. Tất cả các định dạng này đều được hỗ trợ. + > [!TIP] -> Thiết lập API key trong `~/.picoclaw/config.json`. +> Thiết lập API key trong `~/.picoclaw/config.json`, `~/.picoclaw/config.yaml`, `~/.picoclaw/config.toml` hoặc `~/.picoclaw/config.ini`. +> PicoClaw hỗ trợ nhiều định dạng cấu hình: **JSON** (mặc định), **YAML**, **TOML**, **INI** via [spf13/viper](https://github.com/spf13/viper). > Lấy API key: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM) > Tìm kiếm web là **tùy chọn** — lấy [Brave Search API](https://brave.com/search/api) miễn phí (2000 truy vấn/tháng) hoặc dùng tính năng auto fallback tích hợp sẵn. @@ -189,7 +194,13 @@ docker compose --profile gateway up -d picoclaw onboard ``` -**2. Cấu hình** (`~/.picoclaw/config.json`) +**2. Cấu hình** (`~/.picoclaw/config.json`, `~/.picoclaw/config.yaml`, `~/.picoclaw/config.toml` hoặc `~/.picoclaw/config.ini`) + +PicoClaw hỗ trợ nhiều định dạng via [spf13/viper](https://github.com/spf13/viper). Chọn bất kỳ một: +- `config.json` - Định dạng JSON (mặc định) +- `config.yaml` hoặc `config.yml` - Định dạng YAML +- `config.toml` - Định dạng TOML +- `config.ini` - Định dạng INI ```json { diff --git a/README.zh.md b/README.zh.md index 42bd20be4..770a4dfba 100644 --- a/README.zh.md +++ b/README.zh.md @@ -165,7 +165,7 @@ cd picoclaw # 2. 设置 API Key cp config/config.example.json config/config.json -vim config/config.json # 设置 DISCORD_BOT_TOKEN, API keys 等 +vim config/config.json # 或使用 config.yaml/config.toml/config.ini 设置 DISCORD_BOT_TOKEN, API keys 等 # 3. 构建并启动 docker compose --profile gateway up -d @@ -200,7 +200,8 @@ docker compose --profile gateway up -d ### 🚀 快速开始 > [!TIP] -> 在 `~/.picoclaw/config.json` 中设置您的 API Key。 +> 在 `~/.picoclaw/config.json`、`~/.picoclaw/config.yaml`、`~/.picoclaw/config.toml` 或 `~/.picoclaw/config.ini` 中设置您的 API Key。 +> PicoClaw 支持多种配置格式:**JSON**(默认)、**YAML**、**TOML**、**INI**,通过 [spf13/viper](https://github.com/spf13/viper) 驱动。 > 获取 API Key: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu (智谱)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM) > 网络搜索是 **可选的** - 获取免费的 [Brave Search API](https://brave.com/search/api) (每月 2000 次免费查询) @@ -211,7 +212,13 @@ picoclaw onboard ``` -**2. 配置 (Configure)** (`~/.picoclaw/config.json`) +**2. 配置 (Configure)** (`~/.picoclaw/config.json`、`~/.picoclaw/config.yaml`、`~/.picoclaw/config.toml` 或 `~/.picoclaw/config.ini`) + +通过 [spf13/viper](https://github.com/spf13/viper) 支持多种配置格式,选择其中任意一种即可: +- `config.json` - JSON 格式(默认) +- `config.yaml` 或 `config.yml` - YAML 格式 +- `config.toml` - TOML 格式 +- `config.ini` - INI 格式 ```json { diff --git a/build.bat b/build.bat new file mode 100644 index 000000000..4e1b78b00 --- /dev/null +++ b/build.bat @@ -0,0 +1,55 @@ +@echo off +REM Build script for picoclaw on Windows +REM Compiles the picoclaw binary for Windows x64 + +setlocal enabledelayedexpansion + +REM Build variables +set BINARY_NAME=picoclaw +set BUILD_DIR=build +set CMD_DIR=cmd/picoclaw +set MAIN_GO=%CMD_DIR%/main.go + +REM Get git information +for /f "tokens=*" %%i in ('git describe --tags --always --dirty 2^>nul') do set VERSION=%%i +if not defined VERSION set VERSION=dev + +for /f "tokens=*" %%i in ('git rev-parse --short=8 HEAD 2^>nul') do set GIT_COMMIT=%%i +if not defined GIT_COMMIT set GIT_COMMIT=dev + +for /f "tokens=*" %%i in ('powershell -Command "Get-Date -Format 'o'"') do set BUILD_TIME=%%i + +REM Get Go version +for /f "tokens=3" %%i in ('go version') do set GO_VERSION=%%i + +REM Set linker flags +set LDFLAGS=-ldflags "-X main.version=%VERSION% -X main.gitCommit=%GIT_COMMIT% -X main.buildTime=%BUILD_TIME% -X main.goVersion=%GO_VERSION% -s -w" + +REM Run generate +echo Running go generate... +go generate ./... +if errorlevel 1 ( + echo Generate failed! + exit /b 1 +) + +REM Create build directory +if not exist %BUILD_DIR% mkdir %BUILD_DIR% + +REM Build for Windows x64 +echo Building %BINARY_NAME% for Windows x64... +set GOOS=windows +set GOARCH=amd64 +go build -v -tags stdjson %LDFLAGS% -o %BUILD_DIR%\%BINARY_NAME%-windows-amd64.exe ./%CMD_DIR% + +if errorlevel 1 ( + echo Build failed! + exit /b 1 +) + +echo Build complete: %BUILD_DIR%\%BINARY_NAME%-windows-amd64.exe +echo. +echo Build output: +dir %BUILD_DIR%\%BINARY_NAME%-windows-amd64.exe + +endlocal diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index 36bf2ea83..47a966970 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -45,10 +45,11 @@ import ( var embeddedFiles embed.FS var ( - version = "dev" - gitCommit string - buildTime string - goVersion string + version = "dev" + gitCommit string + buildTime string + goVersion string + configPathOverride string ) const logo = "🦞" @@ -125,6 +126,13 @@ func main() { os.Exit(1) } + args, err := parseConfigPathArg(os.Args) + if err != nil { + fmt.Printf("Error: %v\n", err) + os.Exit(1) + } + os.Args = args + command := os.Args[1] switch command { @@ -990,8 +998,32 @@ func authStatusCmd() { } func getConfigPath() string { + if configPathOverride != "" { + return configPathOverride + } home, _ := os.UserHomeDir() - return filepath.Join(home, ".picoclaw", "config.json") + return config.ResolveConfigPath(home) +} + +func parseConfigPathArg(args []string) ([]string, error) { + for i := 2; i < len(args); i++ { + arg := args[i] + if arg == "--config" || arg == "-c" { + if i+1 >= len(args) { + return nil, fmt.Errorf("%s requires a path", arg) + } + configPathOverride = args[i+1] + args = append(args[:i], args[i+2:]...) + i-- + continue + } + if strings.HasPrefix(arg, "--config=") { + configPathOverride = strings.TrimPrefix(arg, "--config=") + args = append(args[:i], args[i+1:]...) + i-- + } + } + return args, nil } func setupCronTool(agentLoop *agent.AgentLoop, msgBus *bus.MessageBus, workspace string, restrict bool, execTimeout time.Duration, config *config.Config) *cron.CronService { diff --git a/config/config.example.ini b/config/config.example.ini new file mode 100644 index 000000000..6fcf8b8b9 --- /dev/null +++ b/config/config.example.ini @@ -0,0 +1,129 @@ +; PicoClaw Configuration Example - INI Format +; This is an alternative to config.json for users who prefer INI format + +[agents.defaults] +workspace=~/.picoclaw/workspace +restrict_to_workspace=true +model=glm-4.7 +max_tokens=8192 +temperature=0.7 +max_tool_iterations=20 + +[channels.telegram] +enabled=false +token=YOUR_TELEGRAM_BOT_TOKEN +proxy= +allow_from=YOUR_USER_ID + +[channels.discord] +enabled=false +token=YOUR_DISCORD_BOT_TOKEN +allow_from= + +[channels.slack] +enabled=false +token=YOUR_SLACK_BOT_TOKEN +allow_from= + +[channels.feishu] +enabled=false +token=YOUR_FEISHU_BOT_TOKEN +secret=YOUR_FEISHU_SECRET +allow_from= + +[channels.dingtalk] +enabled=false +token=YOUR_DINGTALK_BOT_TOKEN +secret=YOUR_DINGTALK_SECRET +allow_from= + +[channels.qq] +enabled=false +account=YOUR_QQ_ACCOUNT +token=YOUR_QQ_BOT_TOKEN +allow_from= + +[channels.whatsapp] +enabled=false +phone_number=YOUR_PHONE_NUMBER +allow_from= + +[channels.line] +enabled=false +token=YOUR_LINE_CHANNEL_ACCESS_TOKEN +secret=YOUR_LINE_CHANNEL_SECRET +allow_from= + +[channels.onebot] +enabled=false +ws_url= +allow_from= + +[channels.maixcam] +enabled=false +host=0.0.0.0 +port=18790 +allow_from= + +[providers.anthropic] +enabled=false +api_key=YOUR_ANTHROPIC_API_KEY +api_base=https://api.anthropic.com + +[providers.openai] +enabled=false +api_key=YOUR_OPENAI_API_KEY +api_base=https://api.openai.com/v1 + +[providers.openrouter] +enabled=false +api_key=YOUR_OPENROUTER_API_KEY +api_base=https://openrouter.ai/api/v1 + +[providers.zhipu] +enabled=false +api_key=YOUR_ZHIPU_API_KEY +api_base=https://open.bigmodel.cn/api/paas/v4 + +[providers.gemini] +enabled=false +api_key=YOUR_GEMINI_API_KEY +api_base=https://generativelanguage.googleapis.com/v1beta/openai/ + +[providers.groq] +enabled=false +api_key=YOUR_GROQ_API_KEY +api_base=https://api.groq.com/openai/v1 + +[providers.vllm] +enabled=false +api_key=YOUR_VLLM_API_KEY +api_base=http://localhost:8000/v1 + +[providers.shengsuan_yun] +enabled=false +api_key=YOUR_SHENGSUAN_YUN_API_KEY +api_base=https://api.shengsuan.cloud/v1 + +[gateway] +enabled=true +host=0.0.0.0 +port=8080 +cors_enabled=true +cors_origins=* + +[tools] +enabled=true + +[heartbeat] +enabled=true +interval=60 +timeout=5 + +[devices] +enabled=true + +[session] +storage=memory +; For persistent storage, set to "sqlite" and provide database_path +; database_path=~/.picoclaw/session.db diff --git a/config/config.example.toml b/config/config.example.toml new file mode 100644 index 000000000..09213d664 --- /dev/null +++ b/config/config.example.toml @@ -0,0 +1,129 @@ +# PicoClaw Configuration Example - TOML Format +# This is an alternative to config.json for users who prefer TOML format + +[agents.defaults] +workspace = "~/.picoclaw/workspace" +restrict_to_workspace = true +model = "glm-4.7" +max_tokens = 8192 +temperature = 0.7 +max_tool_iterations = 20 + +[channels.telegram] +enabled = false +token = "YOUR_TELEGRAM_BOT_TOKEN" +proxy = "" +allow_from = ["YOUR_USER_ID"] + +[channels.discord] +enabled = false +token = "YOUR_DISCORD_BOT_TOKEN" +allow_from = [] + +[channels.slack] +enabled = false +token = "YOUR_SLACK_BOT_TOKEN" +allow_from = [] + +[channels.feishu] +enabled = false +token = "YOUR_FEISHU_BOT_TOKEN" +secret = "YOUR_FEISHU_SECRET" +allow_from = [] + +[channels.dingtalk] +enabled = false +token = "YOUR_DINGTALK_BOT_TOKEN" +secret = "YOUR_DINGTALK_SECRET" +allow_from = [] + +[channels.qq] +enabled = false +account = "YOUR_QQ_ACCOUNT" +token = "YOUR_QQ_BOT_TOKEN" +allow_from = [] + +[channels.whatsapp] +enabled = false +phone_number = "YOUR_PHONE_NUMBER" +allow_from = [] + +[channels.line] +enabled = false +token = "YOUR_LINE_CHANNEL_ACCESS_TOKEN" +secret = "YOUR_LINE_CHANNEL_SECRET" +allow_from = [] + +[channels.onebot] +enabled = false +ws_url = "" +allow_from = [] + +[channels.maixcam] +enabled = false +host = "0.0.0.0" +port = 18790 +allow_from = [] + +[providers.anthropic] +enabled = false +api_key = "YOUR_ANTHROPIC_API_KEY" +api_base = "https://api.anthropic.com" + +[providers.openai] +enabled = false +api_key = "YOUR_OPENAI_API_KEY" +api_base = "https://api.openai.com/v1" + +[providers.openrouter] +enabled = false +api_key = "YOUR_OPENROUTER_API_KEY" +api_base = "https://openrouter.ai/api/v1" + +[providers.zhipu] +enabled = false +api_key = "YOUR_ZHIPU_API_KEY" +api_base = "https://open.bigmodel.cn/api/paas/v4" + +[providers.gemini] +enabled = false +api_key = "YOUR_GEMINI_API_KEY" +api_base = "https://generativelanguage.googleapis.com/v1beta/openai/" + +[providers.groq] +enabled = false +api_key = "YOUR_GROQ_API_KEY" +api_base = "https://api.groq.com/openai/v1" + +[providers.vllm] +enabled = false +api_key = "YOUR_VLLM_API_KEY" +api_base = "http://localhost:8000/v1" + +[providers.shengsuan_yun] +enabled = false +api_key = "YOUR_SHENGSUAN_YUN_API_KEY" +api_base = "https://api.shengsuan.cloud/v1" + +[gateway] +enabled = true +host = "0.0.0.0" +port = 8080 +cors_enabled = true +cors_origins = ["*"] + +[tools] +enabled = true + +[heartbeat] +enabled = true +interval = 60 +timeout = 5 + +[devices] +enabled = true + +[session] +storage = "memory" +# For persistent storage, set to "sqlite" and provide database_path +# database_path = "~/.picoclaw/session.db" diff --git a/config/config.example.yaml b/config/config.example.yaml new file mode 100644 index 000000000..c17327dbf --- /dev/null +++ b/config/config.example.yaml @@ -0,0 +1,134 @@ +# PicoClaw Configuration Example - YAML Format +# This is an alternative to config.json for users who prefer YAML format + +agents: + defaults: + workspace: ~/.picoclaw/workspace + restrict_to_workspace: true + model: glm-4.7 + max_tokens: 8192 + temperature: 0.7 + max_tool_iterations: 20 + +channels: + telegram: + enabled: false + token: YOUR_TELEGRAM_BOT_TOKEN + proxy: "" + allow_from: + - YOUR_USER_ID + + discord: + enabled: false + token: YOUR_DISCORD_BOT_TOKEN + allow_from: [] + + slack: + enabled: false + token: YOUR_SLACK_BOT_TOKEN + allow_from: [] + + feishu: + enabled: false + token: YOUR_FEISHU_BOT_TOKEN + secret: YOUR_FEISHU_SECRET + allow_from: [] + + dingtalk: + enabled: false + token: YOUR_DINGTALK_BOT_TOKEN + secret: YOUR_DINGTALK_SECRET + allow_from: [] + + qq: + enabled: false + account: YOUR_QQ_ACCOUNT + token: YOUR_QQ_BOT_TOKEN + allow_from: [] + + whatsapp: + enabled: false + phone_number: YOUR_PHONE_NUMBER + allow_from: [] + + line: + enabled: false + token: YOUR_LINE_CHANNEL_ACCESS_TOKEN + secret: YOUR_LINE_CHANNEL_SECRET + allow_from: [] + + onebot: + enabled: false + ws_url: "" + allow_from: [] + + maixcam: + enabled: false + host: 0.0.0.0 + port: 18790 + allow_from: [] + +providers: + anthropic: + enabled: false + api_key: YOUR_ANTHROPIC_API_KEY + api_base: https://api.anthropic.com + + openai: + enabled: false + api_key: YOUR_OPENAI_API_KEY + api_base: https://api.openai.com/v1 + + openrouter: + enabled: false + api_key: YOUR_OPENROUTER_API_KEY + api_base: https://openrouter.ai/api/v1 + + zhipu: + enabled: false + api_key: YOUR_ZHIPU_API_KEY + api_base: https://open.bigmodel.cn/api/paas/v4 + + gemini: + enabled: false + api_key: YOUR_GEMINI_API_KEY + api_base: https://generativelanguage.googleapis.com/v1beta/openai/ + + groq: + enabled: false + api_key: YOUR_GROQ_API_KEY + api_base: https://api.groq.com/openai/v1 + + vllm: + enabled: false + api_key: YOUR_VLLM_API_KEY + api_base: http://localhost:8000/v1 + + shengsuan_yun: + enabled: false + api_key: YOUR_SHENGSUAN_YUN_API_KEY + api_base: https://api.shengsuan.cloud/v1 + +gateway: + enabled: true + host: 0.0.0.0 + port: 8080 + cors_enabled: true + cors_origins: + - "*" + +tools: + enabled: true + +heartbeat: + enabled: true + interval: 60 + timeout: 5 + +devices: + enabled: true + +session: + storage: memory + # For persistent storage, set to "sqlite" and provide database_path + # database_path: ~/.picoclaw/session.db diff --git a/go.mod b/go.mod index 1f88639c8..dcff04044 100644 --- a/go.mod +++ b/go.mod @@ -22,7 +22,19 @@ require ( require ( github.com/davecgh/go-spew v1.1.1 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/spf13/viper v1.21.0 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/text v0.34.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 0e95bf5cd..8b161db78 100644 --- a/go.sum +++ b/go.sum @@ -32,6 +32,8 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/github/copilot-sdk/go v0.1.23 h1:uExtO/inZQndCZMiSAA1hvXINiz9tqo/MZgQzFzurxw= github.com/github/copilot-sdk/go v0.1.23/go.mod h1:GdwwBfMbm9AABLEM3x5IZKw4ZfwCYxZ1BgyytmZenQ0= github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w= @@ -41,6 +43,8 @@ github.com/go-resty/resty/v2 v2.17.1/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2m github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -102,14 +106,28 @@ github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 h1:Lb/Uzkiw2Ugt2Xf03J5wmv github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU= github.com/openai/openai-go/v3 v3.22.0 h1:6MEoNoV8sbjOVmXdvhmuX3BjVbVdcExbVyGixiyJ8ys= github.com/openai/openai-go/v3 v3.22.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= github.com/slack-go/slack v0.17.3 h1:zV5qO3Q+WJAQ/XwbGfNFrRMaJ5T/naqaonyPV/1TP4g= github.com/slack-go/slack v0.17.3/go.mod h1:X+UqOufi3LYQHDnMG1vxf0J8asC6+WllXrVrhl8/Prk= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -122,6 +140,8 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/tencent-connect/botgo v0.2.1 h1:+BrTt9Zh+awL28GWC4g5Na3nQaGRWb0N5IctS8WqBCk= github.com/tencent-connect/botgo v0.2.1/go.mod h1:oO1sG9ybhXNickvt+CVym5khwQ+uKhTR+IhTqEfOVsI= github.com/tidwall/gjson v1.9.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= @@ -151,6 +171,8 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y= golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -224,6 +246,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/pkg/config/config.go b/pkg/config/config.go index 682996bd6..817ebff11 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -5,9 +5,12 @@ import ( "fmt" "os" "path/filepath" + "reflect" "sync" "github.com/caarlos0/env/v11" + "github.com/go-viper/mapstructure/v2" + "github.com/spf13/viper" ) // FlexibleStringSlice is a []string that also accepts JSON numbers, @@ -56,6 +59,14 @@ type Config struct { mu sync.RWMutex } +var configFileNames = []string{ + "config.json", + "config.yaml", + "config.yml", + "config.toml", + "config.ini", +} + type AgentsConfig struct { Defaults AgentDefaults `json:"defaults"` List []AgentConfig `json:"list,omitempty"` @@ -65,8 +76,8 @@ type AgentsConfig struct { // String format: "gpt-4" (just primary, no fallbacks) // Object format: {"primary": "gpt-4", "fallbacks": ["claude-haiku"]} type AgentModelConfig struct { - Primary string `json:"primary,omitempty"` - Fallbacks []string `json:"fallbacks,omitempty"` + Primary string `json:"primary" mapstructure:"primary"` + Fallbacks []string `json:"fallbacks,omitempty" mapstructure:"fallbacks"` } func (m *AgentModelConfig) UnmarshalJSON(data []byte) error { @@ -76,30 +87,20 @@ func (m *AgentModelConfig) UnmarshalJSON(data []byte) error { m.Fallbacks = nil return nil } - type raw struct { + + type Alias struct { Primary string `json:"primary"` Fallbacks []string `json:"fallbacks"` } - var r raw - if err := json.Unmarshal(data, &r); err != nil { + aux := &Alias{} + if err := json.Unmarshal(data, aux); err != nil { return err } - m.Primary = r.Primary - m.Fallbacks = r.Fallbacks + m.Primary = aux.Primary + m.Fallbacks = aux.Fallbacks return nil } -func (m AgentModelConfig) MarshalJSON() ([]byte, error) { - if len(m.Fallbacks) == 0 && m.Primary != "" { - return json.Marshal(m.Primary) - } - type raw struct { - Primary string `json:"primary,omitempty"` - Fallbacks []string `json:"fallbacks,omitempty"` - } - return json.Marshal(raw{Primary: m.Primary, Fallbacks: m.Fallbacks}) -} - type AgentConfig struct { ID string `json:"id"` Default bool `json:"default,omitempty"` @@ -139,16 +140,16 @@ type SessionConfig struct { } type AgentDefaults struct { - Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"` - RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"` - Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"` - Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` - ModelFallbacks []string `json:"model_fallbacks,omitempty"` - ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"` - ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"` - MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"` - Temperature float64 `json:"temperature" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"` - MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"` + Workspace string `json:"workspace" mapstructure:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"` + RestrictToWorkspace bool `json:"restrict_to_workspace" mapstructure:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"` + Provider string `json:"provider" mapstructure:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"` + Model string `json:"model" mapstructure:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` + ModelFallbacks []string `json:"model_fallbacks,omitempty" mapstructure:"model_fallbacks,omitempty"` + ImageModel string `json:"image_model,omitempty" mapstructure:"image_model" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"` + ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty" mapstructure:"image_model_fallbacks,omitempty"` + MaxTokens int `json:"max_tokens" mapstructure:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"` + Temperature float64 `json:"temperature" mapstructure:"temperature" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"` + MaxToolIterations int `json:"max_tool_iterations" mapstructure:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"` } type ChannelsConfig struct { @@ -275,7 +276,7 @@ type ProviderConfig struct { type OpenAIProviderConfig struct { ProviderConfig - WebSearch bool `json:"web_search" env:"PICOCLAW_PROVIDERS_OPENAI_WEB_SEARCH"` + WebSearch bool `json:"web_search" mapstructure:"web_search" env:"PICOCLAW_PROVIDERS_OPENAI_WEB_SEARCH"` } type GatewayConfig struct { @@ -454,20 +455,69 @@ func DefaultConfig() *Config { func LoadConfig(path string) (*Config, error) { cfg := DefaultConfig() - data, err := os.ReadFile(path) - if err != nil { - if os.IsNotExist(err) { - return cfg, nil + if _, err := os.Stat(path); os.IsNotExist(err) { + if err := env.Parse(cfg); err != nil { + return nil, err } - return nil, err + return cfg, nil } - if err := json.Unmarshal(data, cfg); err != nil { - return nil, err + v := viper.New() + v.SetConfigFile(path) + + if err := v.ReadInConfig(); err != nil { + return nil, fmt.Errorf("failed to read config file: %w", err) + } + + if err := v.Unmarshal(cfg, func(dc *mapstructure.DecoderConfig) { + dc.TagName = "json" + dc.WeaklyTypedInput = true + dc.Squash = true + dc.DecodeHook = mapstructure.ComposeDecodeHookFunc( + func(_ reflect.Type, t reflect.Type, data interface{}) (interface{}, error) { + modelCfgPtr := reflect.TypeOf((*AgentModelConfig)(nil)) + modelCfgVal := reflect.TypeOf(AgentModelConfig{}) + if t != modelCfgPtr && t != modelCfgVal { + return data, nil + } + + switch raw := data.(type) { + case string: + m := AgentModelConfig{Primary: raw} + if t == modelCfgPtr { + return &m, nil + } + return m, nil + case map[string]interface{}: + m := AgentModelConfig{} + if primary, ok := raw["primary"]; ok { + m.Primary = fmt.Sprintf("%v", primary) + } + if fallbacks, ok := raw["fallbacks"]; ok { + switch fb := fallbacks.(type) { + case []interface{}: + for _, item := range fb { + m.Fallbacks = append(m.Fallbacks, fmt.Sprintf("%v", item)) + } + case []string: + m.Fallbacks = append(m.Fallbacks, fb...) + } + } + if t == modelCfgPtr { + return &m, nil + } + return m, nil + default: + return data, nil + } + }, + ) + }); err != nil { + return nil, fmt.Errorf("failed to decode config data: %w", err) } if err := env.Parse(cfg); err != nil { - return nil, err + return nil, fmt.Errorf("failed to parse env overrides: %w", err) } return cfg, nil @@ -477,17 +527,66 @@ func SaveConfig(path string, cfg *Config) error { cfg.mu.RLock() defer cfg.mu.RUnlock() - data, err := json.MarshalIndent(cfg, "", " ") + ext := filepath.Ext(path) + var configType string + switch ext { + case ".yaml", ".yml": + configType = "yaml" + case ".toml": + configType = "toml" + case ".ini": + configType = "ini" + case ".json", "": + configType = "json" + if ext == "" { + path = path + ".json" + } + default: + configType = "json" + } + + v := viper.New() + v.SetConfigType(configType) + v.SetConfigFile(path) + + data, err := json.Marshal(cfg) if err != nil { - return err + return fmt.Errorf("failed to marshal config: %w", err) + } + + var dataMap map[string]interface{} + if err := json.Unmarshal(data, &dataMap); err != nil { + return fmt.Errorf("failed to unmarshal config map: %w", err) + } + + for key, val := range dataMap { + v.Set(key, val) } dir := filepath.Dir(path) if err := os.MkdirAll(dir, 0755); err != nil { - return err + return fmt.Errorf("failed to create config directory: %w", err) } - return os.WriteFile(path, data, 0600) + if err := v.WriteConfigAs(path); err != nil { + return fmt.Errorf("failed to write config file: %w", err) + } + if err := os.Chmod(path, 0600); err != nil { + return fmt.Errorf("failed to set config file permission: %w", err) + } + + return nil +} + +func ResolveConfigPath(home string) string { + configDir := filepath.Join(home, ".picoclaw") + for _, name := range configFileNames { + path := filepath.Join(configDir, name) + if _, err := os.Stat(path); err == nil { + return path + } + } + return filepath.Join(configDir, "config.json") } func (c *Config) WorkspacePath() string { diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 47916d155..712d8f678 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -38,30 +38,6 @@ func TestAgentModelConfig_UnmarshalObject(t *testing.T) { } } -func TestAgentModelConfig_MarshalString(t *testing.T) { - m := AgentModelConfig{Primary: "gpt-4"} - data, err := json.Marshal(m) - if err != nil { - t.Fatalf("marshal: %v", err) - } - if string(data) != `"gpt-4"` { - t.Errorf("marshal = %s, want '\"gpt-4\"'", string(data)) - } -} - -func TestAgentModelConfig_MarshalObject(t *testing.T) { - m := AgentModelConfig{Primary: "claude-opus", Fallbacks: []string{"haiku"}} - data, err := json.Marshal(m) - if err != nil { - t.Fatalf("marshal: %v", err) - } - var result map[string]interface{} - json.Unmarshal(data, &result) - if result["primary"] != "claude-opus" { - t.Errorf("primary = %v", result["primary"]) - } -} - func TestAgentConfig_FullParse(t *testing.T) { jsonData := `{ "agents": { diff --git a/pkg/config/loadconfig_agentmodel_test.go b/pkg/config/loadconfig_agentmodel_test.go new file mode 100644 index 000000000..0eea1a773 --- /dev/null +++ b/pkg/config/loadconfig_agentmodel_test.go @@ -0,0 +1,98 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadConfig_AgentModelConfigStringFormat(t *testing.T) { + tmpDir := t.TempDir() + jsonFile := filepath.Join(tmpDir, "config.json") + + jsonContent := `{ + "agents": { + "list": [ + { + "id": "test", + "model": "gpt-4" + } + ] + }, + "channels": {}, + "providers": {} +}` + + if err := os.WriteFile(jsonFile, []byte(jsonContent), 0644); err != nil { + t.Fatalf("Failed to write test JSON file: %v", err) + } + + cfg, err := LoadConfig(jsonFile) + if err != nil { + t.Fatalf("Failed to load config with string model format: %v", err) + } + + if len(cfg.Agents.List) == 0 { + t.Fatal("Expected at least one agent") + } + + agent := cfg.Agents.List[0] + if agent.Model == nil { + t.Fatal("Agent model is nil") + } + + if agent.Model.Primary != "gpt-4" { + t.Errorf("Expected model primary 'gpt-4', got '%s'", agent.Model.Primary) + } + + if agent.Model.Fallbacks != nil && len(agent.Model.Fallbacks) > 0 { + t.Errorf("Expected no fallbacks, got %v", agent.Model.Fallbacks) + } +} + +func TestLoadConfig_AgentModelConfigObjectFormat(t *testing.T) { + tmpDir := t.TempDir() + jsonFile := filepath.Join(tmpDir, "config.json") + + jsonContent := `{ + "agents": { + "list": [ + { + "id": "test", + "model": { + "primary": "claude-opus", + "fallbacks": ["gpt-4", "haiku"] + } + } + ] + }, + "channels": {}, + "providers": {} +}` + + if err := os.WriteFile(jsonFile, []byte(jsonContent), 0644); err != nil { + t.Fatalf("Failed to write test JSON file: %v", err) + } + + cfg, err := LoadConfig(jsonFile) + if err != nil { + t.Fatalf("Failed to load config with object model format: %v", err) + } + + if len(cfg.Agents.List) == 0 { + t.Fatal("Expected at least one agent") + } + + agent := cfg.Agents.List[0] + if agent.Model == nil { + t.Fatal("Agent model is nil") + } + + if agent.Model.Primary != "claude-opus" { + t.Errorf("Expected model primary 'claude-opus', got '%s'", agent.Model.Primary) + } + + if len(agent.Model.Fallbacks) != 2 { + t.Errorf("Expected 2 fallbacks, got %d", len(agent.Model.Fallbacks)) + } +} diff --git a/pkg/config/path_resolution_test.go b/pkg/config/path_resolution_test.go new file mode 100644 index 000000000..35c2cede6 --- /dev/null +++ b/pkg/config/path_resolution_test.go @@ -0,0 +1,54 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestResolveConfigPath_DefaultJSONWhenMissing(t *testing.T) { + home := t.TempDir() + got := ResolveConfigPath(home) + want := filepath.Join(home, ".picoclaw", "config.json") + if got != want { + t.Fatalf("ResolveConfigPath() = %q, want %q", got, want) + } +} + +func TestResolveConfigPath_PicksExistingNonJSON(t *testing.T) { + home := t.TempDir() + configDir := filepath.Join(home, ".picoclaw") + if err := os.MkdirAll(configDir, 0o755); err != nil { + t.Fatalf("MkdirAll() error: %v", err) + } + yamlPath := filepath.Join(configDir, "config.yaml") + if err := os.WriteFile(yamlPath, []byte("agents: {}"), 0o600); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + got := ResolveConfigPath(home) + if got != yamlPath { + t.Fatalf("ResolveConfigPath() = %q, want %q", got, yamlPath) + } +} + +func TestResolveConfigPath_PrefersJSONWhenBothExist(t *testing.T) { + home := t.TempDir() + configDir := filepath.Join(home, ".picoclaw") + if err := os.MkdirAll(configDir, 0o755); err != nil { + t.Fatalf("MkdirAll() error: %v", err) + } + yamlPath := filepath.Join(configDir, "config.yaml") + if err := os.WriteFile(yamlPath, []byte("agents: {}"), 0o600); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + jsonPath := filepath.Join(configDir, "config.json") + if err := os.WriteFile(jsonPath, []byte("{}"), 0o600); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + got := ResolveConfigPath(home) + if got != jsonPath { + t.Fatalf("ResolveConfigPath() = %q, want %q", got, jsonPath) + } +} diff --git a/pkg/config/viper_config_test.go b/pkg/config/viper_config_test.go new file mode 100644 index 000000000..972b72e20 --- /dev/null +++ b/pkg/config/viper_config_test.go @@ -0,0 +1,113 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadConfig_YAML(t *testing.T) { + tmpDir := t.TempDir() + yamlFile := filepath.Join(tmpDir, "config.yaml") + + yamlContent := ` +agents: + defaults: + workspace: ~/.picoclaw/workspace + model: gpt-4 + max_tokens: 8192 + temperature: 0.7 + max_tool_iterations: 20 +channels: {} +providers: {} +` + + if err := os.WriteFile(yamlFile, []byte(yamlContent), 0644); err != nil { + t.Fatalf("Failed to write test YAML file: %v", err) + } + + cfg, err := LoadConfig(yamlFile) + if err != nil { + t.Fatalf("Failed to load YAML config: %v", err) + } + + if cfg.Agents.Defaults.Model != "gpt-4" { + t.Errorf("Expected model 'gpt-4', got '%s'", cfg.Agents.Defaults.Model) + } + + if cfg.Agents.Defaults.MaxTokens != 8192 { + t.Errorf("Expected max_tokens 8192, got %d", cfg.Agents.Defaults.MaxTokens) + } +} + +func TestLoadConfig_JSON(t *testing.T) { + tmpDir := t.TempDir() + jsonFile := filepath.Join(tmpDir, "config.json") + + jsonContent := `{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "model": "claude-3", + "max_tokens": 4096 + } + }, + "channels": {}, + "providers": {} +}` + + if err := os.WriteFile(jsonFile, []byte(jsonContent), 0644); err != nil { + t.Fatalf("Failed to write test JSON file: %v", err) + } + + cfg, err := LoadConfig(jsonFile) + if err != nil { + t.Fatalf("Failed to load JSON config: %v", err) + } + + if cfg.Agents.Defaults.Model != "claude-3" { + t.Errorf("Expected model 'claude-3', got '%s'", cfg.Agents.Defaults.Model) + } + + if cfg.Agents.Defaults.MaxTokens != 4096 { + t.Errorf("Expected max_tokens 4096, got %d", cfg.Agents.Defaults.MaxTokens) + } +} + +func TestLoadConfig_NonExistent(t *testing.T) { + cfg, err := LoadConfig("/nonexistent/path/config.yaml") + if err != nil { + t.Errorf("Should not return error for non-existent file, got: %v", err) + } + + if cfg == nil { + t.Error("Should return default config for non-existent file") + } +} + +func TestLoadConfig_YAMLProviderAPIKey(t *testing.T) { + tmpDir := t.TempDir() + yamlFile := filepath.Join(tmpDir, "config.yaml") + + yamlContent := ` +agents: + defaults: + model: glm-4.7 +providers: + zhipu: + api_key: test-zhipu-key +` + + if err := os.WriteFile(yamlFile, []byte(yamlContent), 0644); err != nil { + t.Fatalf("Failed to write test YAML file: %v", err) + } + + cfg, err := LoadConfig(yamlFile) + if err != nil { + t.Fatalf("Failed to load YAML config: %v", err) + } + + if cfg.Providers.Zhipu.APIKey != "test-zhipu-key" { + t.Fatalf("Expected zhipu api_key to be loaded, got %q", cfg.Providers.Zhipu.APIKey) + } +} diff --git a/workspace/skills/summarize/SKILL.md b/workspace/skills/summarize/SKILL.md index 766ab5d0b..66a2c9b4b 100644 --- a/workspace/skills/summarize/SKILL.md +++ b/workspace/skills/summarize/SKILL.md @@ -56,12 +56,29 @@ Default model is `google/gemini-3-flash-preview` if none is set. ## Config -Optional config file: `~/.summarize/config.json` +Optional config file: `~/.summarize/config.json`, `~/.summarize/config.yaml`, `~/.summarize/config.toml`, or `~/.summarize/config.ini` + +PicoClaw supports multiple configuration formats via [spf13/viper](https://github.com/spf13/viper): ```json { "model": "openai/gpt-5.2" } ``` +Or in YAML format: +```yaml +model: openai/gpt-5.2 +``` + +Or in TOML format: +```toml +model = "openai/gpt-5.2" +``` + +Or in INI format: +```ini +model = openai/gpt-5.2 +``` + Optional services: - `FIRECRAWL_API_KEY` for blocked sites - `APIFY_API_TOKEN` for YouTube fallback