feat(config): add YAML configuration file support

Users can now use either config.json or config.yml for configuration.
The system automatically detects which format to use based on the file
extension. JSON remains the default if no config file exists yet.

- Add YAML unmarshaling with proper type normalization
- Update getConfigPath() to check for both formats
- Update all documentation to reflect dual format support
- Add tests for YAML load and save operations
This commit is contained in:
wesong 2026-02-18 22:00:23 +08:00
parent eda6e37332
commit 4b99d49569
9 changed files with 184 additions and 22 deletions

View file

@ -160,7 +160,7 @@ docker compose --profile gateway up -d
### 🚀 クイックスタート(ネイティブ)
> [!TIP]
> `~/.picoclaw/config.json` に API キーを設定してください。
> API キーは `~/.picoclaw/config.json`(または `~/.picoclaw/config.yml`)に設定してください。
> 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 +170,7 @@ docker compose --profile gateway up -d
picoclaw onboard
```
**2. 設定** (`~/.picoclaw/config.json`)
**2. 設定** (`~/.picoclaw/config.json` または `~/.picoclaw/config.yml`)
```json
{
@ -432,7 +432,7 @@ picoclaw gateway
## ⚙️ 設定
設定ファイル: `~/.picoclaw/config.json`
設定ファイル: `~/.picoclaw/config.json``~/.picoclaw/config.yml` も対応)
### ワークスペース構成
@ -741,7 +741,7 @@ Discord: https://discord.gg/V4sAZ9XWpN
Web 検索を有効にするには:
1. [https://brave.com/search/api](https://brave.com/search/api) で無料の API キーを取得(月 2000 クエリ無料)
2. `~/.picoclaw/config.json` に追加:
2. 設定ファイル(`~/.picoclaw/config.json` または `~/.picoclaw/config.yml`に追加:
```json
{
"tools": {

View file

@ -192,7 +192,7 @@ 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` (or `~/.picoclaw/config.yml`).
> 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 +202,7 @@ docker compose --profile gateway up -d
picoclaw onboard
```
**2. Configure** (`~/.picoclaw/config.json`)
**2. Configure** (`~/.picoclaw/config.json` or `~/.picoclaw/config.yml`)
```json
{
@ -472,7 +472,7 @@ Connect Picoclaw to the Agent Social Network simply by sending a single message
## ⚙️ Configuration
Config file: `~/.picoclaw/config.json`
Config file: `~/.picoclaw/config.json` (also supports `~/.picoclaw/config.yml`)
### Workspace Layout
@ -835,7 +835,7 @@ To enable web search:
1. **Option 1 (Recommended)**: Get a free API key at [https://brave.com/search/api](https://brave.com/search/api) (2000 free queries/month) for the best results.
2. **Option 2 (No Credit Card)**: If you don't have a key, we automatically fall back to **DuckDuckGo** (no key required).
Add the key to `~/.picoclaw/config.json` if using Brave:
Add the key to your config file (`~/.picoclaw/config.json` or `~/.picoclaw/config.yml`) if using Brave:
```json
{

View file

@ -198,7 +198,7 @@ docker compose --profile gateway up -d
### 🚀 Inicio Rapido
> [!TIP]
> Configure sua API key em `~/.picoclaw/config.json`.
> Configure sua API key em `~/.picoclaw/config.json` (ou `~/.picoclaw/config.yml`).
> 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 gratis/mes) ou use o fallback automatico integrado.
@ -208,7 +208,7 @@ docker compose --profile gateway up -d
picoclaw onboard
```
**2. Configurar** (`~/.picoclaw/config.json`)
**2. Configurar** (`~/.picoclaw/config.json` ou `~/.picoclaw/config.yml`)
```json
{
@ -478,7 +478,7 @@ Conecte o PicoClaw a Rede Social de Agentes simplesmente enviando uma unica mens
## ⚙️ Configuracao Detalhada
Arquivo de configuracao: `~/.picoclaw/config.json`
Arquivo de configuracao: `~/.picoclaw/config.json` (tambem suporta `~/.picoclaw/config.yml`)
### Estrutura do Workspace
@ -841,7 +841,7 @@ Para habilitar a busca web:
1. **Opcao 1 (Recomendado)**: Obtenha uma API key gratuita em [https://brave.com/search/api](https://brave.com/search/api) (2000 consultas gratis/mes) para os melhores resultados.
2. **Opcao 2 (Sem Cartao de Credito)**: Se voce nao tem uma key, o sistema automaticamente usa o **DuckDuckGo** como fallback (sem necessidade de key).
Adicione a key em `~/.picoclaw/config.json` se usar o Brave:
Adicione a key no arquivo de configuracao (`~/.picoclaw/config.json` ou `~/.picoclaw/config.yml`) se usar o Brave:
```json
{

View file

@ -200,7 +200,7 @@ docker compose --profile gateway up -d
### 🚀 快速开始
> [!TIP]
> 在 `~/.picoclaw/config.json` 中设置您的 API Key
> 在配置文件中设置您的 API Key`~/.picoclaw/config.json`(或 `~/.picoclaw/config.yml`
> 获取 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 +211,7 @@ picoclaw onboard
```
**2. 配置 (Configure)** (`~/.picoclaw/config.json`)
**2. 配置 (Configure)** (`~/.picoclaw/config.json``~/.picoclaw/config.yml`)
```json
{
@ -438,7 +438,7 @@ picoclaw gateway
## ⚙️ 配置详解
配置文件路径: `~/.picoclaw/config.json`
配置文件路径: `~/.picoclaw/config.json`(也支持 `~/.picoclaw/config.yml`
### 工作区布局 (Workspace Layout)
@ -707,7 +707,7 @@ Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN)
启用网络搜索:
1. 在 [https://brave.com/search/api](https://brave.com/search/api) 获取免费 API Key (每月 2000 次免费查询)
2. 添加到 `~/.picoclaw/config.json`:
2. 添加到配置文件(`~/.picoclaw/config.json``~/.picoclaw/config.yml`:
```json
{
"tools": {

View file

@ -985,7 +985,19 @@ func authStatusCmd() {
func getConfigPath() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".picoclaw", "config.json")
configDir := filepath.Join(home, ".picoclaw")
jsonPath := filepath.Join(configDir, "config.json")
if _, err := os.Stat(jsonPath); err == nil {
return jsonPath
}
ymlPath := filepath.Join(configDir, "config.yml")
if _, err := os.Stat(ymlPath); err == nil {
return ymlPath
}
return jsonPath
}
func setupCronTool(agentLoop *agent.AgentLoop, msgBus *bus.MessageBus, workspace string, restrict bool, execTimeout time.Duration, config *config.Config) *cron.CronService {

View file

@ -1,6 +1,6 @@
# Tools Configuration
PicoClaw's tools configuration is located in the `tools` field of `config.json`.
PicoClaw's tools configuration is located in the `tools` field of your config file (`config.json` or `config.yml`).
## Directory Structure

View file

@ -116,7 +116,7 @@ func (c *cmd) List(ctx context.Context, message telego.Message) error {
if provider == "" {
provider = "configured default"
}
response = fmt.Sprintf("Configured Model: %s\nProvider: %s\n\nTo change models, update config.yaml",
response = fmt.Sprintf("Configured Model: %s\nProvider: %s\n\nTo change models, update config.json/config.yml",
c.config.Agents.Defaults.Model, provider)
case "channels":

View file

@ -5,9 +5,11 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"github.com/caarlos0/env/v11"
"gopkg.in/yaml.v3"
)
// FlexibleStringSlice is a []string that also accepts JSON numbers,
@ -379,7 +381,7 @@ func LoadConfig(path string) (*Config, error) {
return nil, err
}
if err := json.Unmarshal(data, cfg); err != nil {
if err := unmarshalConfigData(path, data, cfg); err != nil {
return nil, err
}
@ -394,7 +396,7 @@ func SaveConfig(path string, cfg *Config) error {
cfg.mu.RLock()
defer cfg.mu.RUnlock()
data, err := json.MarshalIndent(cfg, "", " ")
data, err := marshalConfigData(path, cfg)
if err != nil {
return err
}
@ -407,6 +409,75 @@ func SaveConfig(path string, cfg *Config) error {
return os.WriteFile(path, data, 0600)
}
func isYAMLPath(path string) bool {
ext := strings.ToLower(filepath.Ext(path))
return ext == ".yml"
}
func unmarshalConfigData(path string, data []byte, cfg *Config) error {
if isYAMLPath(path) {
return unmarshalYAMLIntoConfig(data, cfg)
}
return json.Unmarshal(data, cfg)
}
func unmarshalYAMLIntoConfig(data []byte, cfg *Config) error {
var raw interface{}
if err := yaml.Unmarshal(data, &raw); err != nil {
return err
}
normalized := normalizeYAMLValue(raw)
jsonData, err := json.Marshal(normalized)
if err != nil {
return err
}
return json.Unmarshal(jsonData, cfg)
}
func normalizeYAMLValue(v interface{}) interface{} {
switch t := v.(type) {
case map[string]interface{}:
m := make(map[string]interface{}, len(t))
for k, vv := range t {
m[k] = normalizeYAMLValue(vv)
}
return m
case map[interface{}]interface{}:
m := make(map[string]interface{}, len(t))
for k, vv := range t {
m[fmt.Sprintf("%v", k)] = normalizeYAMLValue(vv)
}
return m
case []interface{}:
out := make([]interface{}, len(t))
for i := range t {
out[i] = normalizeYAMLValue(t[i])
}
return out
default:
return v
}
}
func marshalConfigData(path string, cfg *Config) ([]byte, error) {
if !isYAMLPath(path) {
return json.MarshalIndent(cfg, "", " ")
}
jsonData, err := json.Marshal(cfg)
if err != nil {
return nil, err
}
var raw map[string]interface{}
if err := json.Unmarshal(jsonData, &raw); err != nil {
return nil, err
}
return yaml.Marshal(raw)
}
func (c *Config) WorkspacePath() string {
c.mu.RLock()
defer c.mu.RUnlock()

View file

@ -4,6 +4,7 @@ import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
@ -243,3 +244,81 @@ func TestLoadConfig_OpenAIWebSearchCanBeDisabled(t *testing.T) {
t.Fatal("OpenAI codex web search should be false when disabled in config file")
}
}
func TestLoadConfig_YAML(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.yml")
data := `
agents:
defaults:
model: gpt-4o-mini
providers:
openai:
web_search: false
channels:
telegram:
enabled: true
`
if err := os.WriteFile(configPath, []byte(data), 0o600); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
cfg, err := LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error: %v", err)
}
if cfg.Agents.Defaults.Model != "gpt-4o-mini" {
t.Fatalf("Agents.Defaults.Model = %q, want %q", cfg.Agents.Defaults.Model, "gpt-4o-mini")
}
if cfg.Providers.OpenAI.WebSearch {
t.Fatal("OpenAI web_search should be false from YAML")
}
if !cfg.Channels.Telegram.Enabled {
t.Fatal("Channels.Telegram.Enabled should be true from YAML")
}
}
func TestSaveConfig_YAML(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.yml")
cfg := DefaultConfig()
cfg.Agents.Defaults.Model = "test-model"
if err := SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error: %v", err)
}
raw, err := os.ReadFile(configPath)
if err != nil {
t.Fatalf("ReadFile() error: %v", err)
}
content := string(raw)
if !strings.Contains(content, "agents:") {
t.Fatalf("yaml output should contain root key 'agents', got: %s", content)
}
if !strings.Contains(content, "defaults:") {
t.Fatalf("yaml output should contain key 'defaults', got: %s", content)
}
if !strings.Contains(content, "model: test-model") {
t.Fatalf("yaml output should contain updated model, got: %s", content)
}
}
func TestLoadConfig_YAML_ExtensionYAMLNotSupported(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.yaml")
data := `
agents:
defaults:
model: gpt-4o-mini
`
if err := os.WriteFile(configPath, []byte(data), 0o600); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
if _, err := LoadConfig(configPath); err == nil {
t.Fatal("LoadConfig() should fail for .yaml; only .yml is supported")
}
}