feat: add Jules API integration as a CLI command\n\n- Added JulesConfig to pkg/config/tools.go to store API Key configuration.\n- Created a new CLI command package cmd/picoclaw/internal/jules to interact with Jules API (manage sessions, list activities, approve plans, etc.).\n- Registered the command into the main.go entrypoint.\n- Wrote comprehensive unit tests mocking HTTP requests to verify command functionality.

Co-authored-by: hobbyistlabs-coder <267281733+hobbyistlabs-coder@users.noreply.github.com>
This commit is contained in:
google-labs-jules[bot] 2026-03-14 04:54:56 +00:00
parent a41d5d1bcc
commit f61a148e7c
9 changed files with 1379 additions and 0 deletions

View file

@ -0,0 +1,214 @@
package jules
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"github.com/spf13/cobra"
"jane/cmd/picoclaw/internal"
)
var julesBaseURL = "https://jules.googleapis.com/v1alpha"
func getAPIKey() (string, error) {
if key := os.Getenv("JULES_API_KEY"); key != "" {
return key, nil
}
cfg, err := internal.LoadConfig()
if err != nil {
return "", fmt.Errorf("failed to load config: %w", err)
}
if cfg.Tools.Jules.APIKey != "" {
return cfg.Tools.Jules.APIKey, nil
}
return "", fmt.Errorf("JULES_API_KEY environment variable or config value not set")
}
func doRequest(method, url string, body []byte) error {
apiKey, err := getAPIKey()
if err != nil {
return err
}
req, err := http.NewRequest(method, url, bytes.NewBuffer(body))
if err != nil {
return fmt.Errorf("error creating request: %w", err)
}
req.Header.Set("x-goog-api-key", apiKey)
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("error making request: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("error reading response: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("API error (%d): %s", resp.StatusCode, string(respBody))
}
if len(respBody) > 0 {
var prettyJSON bytes.Buffer
if err := json.Indent(&prettyJSON, respBody, "", " "); err == nil {
fmt.Println(prettyJSON.String())
} else {
fmt.Println(string(respBody))
}
} else {
fmt.Println("Success")
}
return nil
}
func NewJulesCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "jules",
Short: "Manage Jules sessions and activities",
}
cmd.AddCommand(newSessionCmd())
cmd.AddCommand(newActivityCmd())
return cmd
}
func newSessionCmd() *cobra.Command {
sessionCmd := &cobra.Command{
Use: "session",
Short: "Manage Jules sessions",
}
var prompt string
var title string
var source string
var branch string
createCmd := &cobra.Command{
Use: "create",
Short: "Create a new session",
RunE: func(cmd *cobra.Command, args []string) error {
if prompt == "" || source == "" {
return fmt.Errorf("--prompt and --source are required")
}
payload := map[string]interface{}{
"prompt": prompt,
"sourceContext": map[string]interface{}{
"source": source,
},
}
if title != "" {
payload["title"] = title
}
if branch != "" {
payload["sourceContext"].(map[string]interface{})["githubRepoContext"] = map[string]interface{}{
"startingBranch": branch,
}
}
body, _ := json.Marshal(payload)
return doRequest("POST", julesBaseURL+"/sessions", body)
},
}
createCmd.Flags().StringVar(&prompt, "prompt", "", "The prompt for the session")
createCmd.Flags().StringVar(&title, "title", "", "The title of the session")
createCmd.Flags().StringVar(&source, "source", "", "The source repository (e.g., sources/github-owner-repo)")
createCmd.Flags().StringVar(&branch, "branch", "", "The starting branch")
listCmd := &cobra.Command{
Use: "list",
Short: "List sessions",
RunE: func(cmd *cobra.Command, args []string) error {
return doRequest("GET", julesBaseURL+"/sessions", nil)
},
}
getCmd := &cobra.Command{
Use: "get [sessionId]",
Short: "Get a session by ID",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return doRequest("GET", julesBaseURL+"/sessions/"+args[0], nil)
},
}
deleteCmd := &cobra.Command{
Use: "delete [sessionId]",
Short: "Delete a session by ID",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return doRequest("DELETE", julesBaseURL+"/sessions/"+args[0], nil)
},
}
var message string
messageCmd := &cobra.Command{
Use: "message [sessionId]",
Short: "Send a message to a session",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if message == "" {
return fmt.Errorf("--message is required")
}
payload := map[string]interface{}{
"prompt": message,
}
body, _ := json.Marshal(payload)
return doRequest("POST", julesBaseURL+"/sessions/"+args[0]+":sendMessage", body)
},
}
messageCmd.Flags().StringVar(&message, "message", "", "The message to send")
approveCmd := &cobra.Command{
Use: "approve [sessionId]",
Short: "Approve a session plan",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return doRequest("POST", julesBaseURL+"/sessions/"+args[0]+":approvePlan", []byte("{}"))
},
}
sessionCmd.AddCommand(createCmd, listCmd, getCmd, deleteCmd, messageCmd, approveCmd)
return sessionCmd
}
func newActivityCmd() *cobra.Command {
activityCmd := &cobra.Command{
Use: "activity",
Short: "Manage Jules activities",
}
listCmd := &cobra.Command{
Use: "list [sessionId]",
Short: "List activities for a session",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return doRequest("GET", julesBaseURL+"/sessions/"+args[0]+"/activities", nil)
},
}
getCmd := &cobra.Command{
Use: "get [sessionId] [activityId]",
Short: "Get an activity by ID",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
return doRequest("GET", julesBaseURL+"/sessions/"+args[0]+"/activities/"+args[1], nil)
},
}
activityCmd.AddCommand(listCmd, getCmd)
return activityCmd
}

View file

@ -0,0 +1,214 @@
package jules
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"github.com/spf13/cobra"
"jane/cmd/picoclaw/internal"
)
const julesBaseURL = "https://jules.googleapis.com/v1alpha"
func getAPIKey() (string, error) {
if key := os.Getenv("JULES_API_KEY"); key != "" {
return key, nil
}
cfg, err := internal.LoadConfig()
if err != nil {
return "", fmt.Errorf("failed to load config: %w", err)
}
if cfg.Tools.Jules.APIKey != "" {
return cfg.Tools.Jules.APIKey, nil
}
return "", fmt.Errorf("JULES_API_KEY environment variable or config value not set")
}
func doRequest(method, url string, body []byte) error {
apiKey, err := getAPIKey()
if err != nil {
return err
}
req, err := http.NewRequest(method, url, bytes.NewBuffer(body))
if err != nil {
return fmt.Errorf("error creating request: %w", err)
}
req.Header.Set("x-goog-api-key", apiKey)
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("error making request: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("error reading response: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("API error (%d): %s", resp.StatusCode, string(respBody))
}
if len(respBody) > 0 {
var prettyJSON bytes.Buffer
if err := json.Indent(&prettyJSON, respBody, "", " "); err == nil {
fmt.Println(prettyJSON.String())
} else {
fmt.Println(string(respBody))
}
} else {
fmt.Println("Success")
}
return nil
}
func NewJulesCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "jules",
Short: "Manage Jules sessions and activities",
}
cmd.AddCommand(newSessionCmd())
cmd.AddCommand(newActivityCmd())
return cmd
}
func newSessionCmd() *cobra.Command {
sessionCmd := &cobra.Command{
Use: "session",
Short: "Manage Jules sessions",
}
var prompt string
var title string
var source string
var branch string
createCmd := &cobra.Command{
Use: "create",
Short: "Create a new session",
RunE: func(cmd *cobra.Command, args []string) error {
if prompt == "" || source == "" {
return fmt.Errorf("--prompt and --source are required")
}
payload := map[string]interface{}{
"prompt": prompt,
"sourceContext": map[string]interface{}{
"source": source,
},
}
if title != "" {
payload["title"] = title
}
if branch != "" {
payload["sourceContext"].(map[string]interface{})["githubRepoContext"] = map[string]interface{}{
"startingBranch": branch,
}
}
body, _ := json.Marshal(payload)
return doRequest("POST", julesBaseURL+"/sessions", body)
},
}
createCmd.Flags().StringVar(&prompt, "prompt", "", "The prompt for the session")
createCmd.Flags().StringVar(&title, "title", "", "The title of the session")
createCmd.Flags().StringVar(&source, "source", "", "The source repository (e.g., sources/github-owner-repo)")
createCmd.Flags().StringVar(&branch, "branch", "", "The starting branch")
listCmd := &cobra.Command{
Use: "list",
Short: "List sessions",
RunE: func(cmd *cobra.Command, args []string) error {
return doRequest("GET", julesBaseURL+"/sessions", nil)
},
}
getCmd := &cobra.Command{
Use: "get [sessionId]",
Short: "Get a session by ID",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return doRequest("GET", julesBaseURL+"/sessions/"+args[0], nil)
},
}
deleteCmd := &cobra.Command{
Use: "delete [sessionId]",
Short: "Delete a session by ID",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return doRequest("DELETE", julesBaseURL+"/sessions/"+args[0], nil)
},
}
var message string
messageCmd := &cobra.Command{
Use: "message [sessionId]",
Short: "Send a message to a session",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if message == "" {
return fmt.Errorf("--message is required")
}
payload := map[string]interface{}{
"prompt": message,
}
body, _ := json.Marshal(payload)
return doRequest("POST", julesBaseURL+"/sessions/"+args[0]+":sendMessage", body)
},
}
messageCmd.Flags().StringVar(&message, "message", "", "The message to send")
approveCmd := &cobra.Command{
Use: "approve [sessionId]",
Short: "Approve a session plan",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return doRequest("POST", julesBaseURL+"/sessions/"+args[0]+":approvePlan", []byte("{}"))
},
}
sessionCmd.AddCommand(createCmd, listCmd, getCmd, deleteCmd, messageCmd, approveCmd)
return sessionCmd
}
func newActivityCmd() *cobra.Command {
activityCmd := &cobra.Command{
Use: "activity",
Short: "Manage Jules activities",
}
listCmd := &cobra.Command{
Use: "list [sessionId]",
Short: "List activities for a session",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return doRequest("GET", julesBaseURL+"/sessions/"+args[0]+"/activities", nil)
},
}
getCmd := &cobra.Command{
Use: "get [sessionId] [activityId]",
Short: "Get an activity by ID",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
return doRequest("GET", julesBaseURL+"/sessions/"+args[0]+"/activities/"+args[1], nil)
},
}
activityCmd.AddCommand(listCmd, getCmd)
return activityCmd
}

View file

@ -0,0 +1,192 @@
package jules
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestJulesCommands(t *testing.T) {
os.Setenv("JULES_API_KEY", "test-api-key")
defer os.Unsetenv("JULES_API_KEY")
var lastReq *http.Request
var lastBody []byte
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
lastReq = r
var err error
lastBody, err = io.ReadAll(r.Body)
require.NoError(t, err)
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status": "ok"}`))
}))
defer ts.Close()
originalBaseURL := julesBaseURL
julesBaseURL = ts.URL
defer func() { julesBaseURL = originalBaseURL }()
t.Run("session create", func(t *testing.T) {
cmd := NewJulesCommand()
cmd.SetArgs([]string{"session", "create", "--prompt", "test prompt", "--source", "sources/test", "--title", "test title", "--branch", "main"})
// Capture stdout
oldStdout := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
err := cmd.Execute()
require.NoError(t, err)
w.Close()
os.Stdout = oldStdout
var buf bytes.Buffer
io.Copy(&buf, r)
assert.Equal(t, "POST", lastReq.Method)
assert.Equal(t, "/sessions", lastReq.URL.Path)
assert.Equal(t, "test-api-key", lastReq.Header.Get("x-goog-api-key"))
expectedBody := `{"prompt":"test prompt","sourceContext":{"githubRepoContext":{"startingBranch":"main"},"source":"sources/test"},"title":"test title"}`
// unmarshal and marshal again to compare json ignoring key order
assert.JSONEq(t, expectedBody, string(lastBody))
assert.Contains(t, buf.String(), `"status": "ok"`)
})
t.Run("session list", func(t *testing.T) {
cmd := NewJulesCommand()
cmd.SetArgs([]string{"session", "list"})
oldStdout := os.Stdout
_, w, _ := os.Pipe()
os.Stdout = w
err := cmd.Execute()
require.NoError(t, err)
w.Close()
os.Stdout = oldStdout
assert.Equal(t, "GET", lastReq.Method)
assert.Equal(t, "/sessions", lastReq.URL.Path)
assert.Equal(t, "test-api-key", lastReq.Header.Get("x-goog-api-key"))
})
t.Run("session get", func(t *testing.T) {
cmd := NewJulesCommand()
cmd.SetArgs([]string{"session", "get", "123"})
oldStdout := os.Stdout
_, w, _ := os.Pipe()
os.Stdout = w
err := cmd.Execute()
require.NoError(t, err)
w.Close()
os.Stdout = oldStdout
assert.Equal(t, "GET", lastReq.Method)
assert.Equal(t, "/sessions/123", lastReq.URL.Path)
})
t.Run("session delete", func(t *testing.T) {
cmd := NewJulesCommand()
cmd.SetArgs([]string{"session", "delete", "123"})
oldStdout := os.Stdout
_, w, _ := os.Pipe()
os.Stdout = w
err := cmd.Execute()
require.NoError(t, err)
w.Close()
os.Stdout = oldStdout
assert.Equal(t, "DELETE", lastReq.Method)
assert.Equal(t, "/sessions/123", lastReq.URL.Path)
})
t.Run("session message", func(t *testing.T) {
cmd := NewJulesCommand()
cmd.SetArgs([]string{"session", "message", "123", "--message", "hello jules"})
oldStdout := os.Stdout
_, w, _ := os.Pipe()
os.Stdout = w
err := cmd.Execute()
require.NoError(t, err)
w.Close()
os.Stdout = oldStdout
assert.Equal(t, "POST", lastReq.Method)
assert.Equal(t, "/sessions/123:sendMessage", lastReq.URL.Path)
assert.JSONEq(t, `{"prompt":"hello jules"}`, string(lastBody))
})
t.Run("session approve", func(t *testing.T) {
cmd := NewJulesCommand()
cmd.SetArgs([]string{"session", "approve", "123"})
oldStdout := os.Stdout
_, w, _ := os.Pipe()
os.Stdout = w
err := cmd.Execute()
require.NoError(t, err)
w.Close()
os.Stdout = oldStdout
assert.Equal(t, "POST", lastReq.Method)
assert.Equal(t, "/sessions/123:approvePlan", lastReq.URL.Path)
assert.JSONEq(t, `{}`, string(lastBody))
})
t.Run("activity list", func(t *testing.T) {
cmd := NewJulesCommand()
cmd.SetArgs([]string{"activity", "list", "123"})
oldStdout := os.Stdout
_, w, _ := os.Pipe()
os.Stdout = w
err := cmd.Execute()
require.NoError(t, err)
w.Close()
os.Stdout = oldStdout
assert.Equal(t, "GET", lastReq.Method)
assert.Equal(t, "/sessions/123/activities", lastReq.URL.Path)
})
t.Run("activity get", func(t *testing.T) {
cmd := NewJulesCommand()
cmd.SetArgs([]string{"activity", "get", "123", "act1"})
oldStdout := os.Stdout
_, w, _ := os.Pipe()
os.Stdout = w
err := cmd.Execute()
require.NoError(t, err)
w.Close()
os.Stdout = oldStdout
assert.Equal(t, "GET", lastReq.Method)
assert.Equal(t, "/sessions/123/activities/act1", lastReq.URL.Path)
})
}

View file

@ -17,6 +17,7 @@ import (
"jane/cmd/picoclaw/internal/auth" "jane/cmd/picoclaw/internal/auth"
"jane/cmd/picoclaw/internal/cron" "jane/cmd/picoclaw/internal/cron"
"jane/cmd/picoclaw/internal/gateway" "jane/cmd/picoclaw/internal/gateway"
"jane/cmd/picoclaw/internal/jules"
"jane/cmd/picoclaw/internal/migrate" "jane/cmd/picoclaw/internal/migrate"
"jane/cmd/picoclaw/internal/onboard" "jane/cmd/picoclaw/internal/onboard"
"jane/cmd/picoclaw/internal/skills" "jane/cmd/picoclaw/internal/skills"
@ -41,6 +42,7 @@ func NewPicoclawCommand() *cobra.Command {
gateway.NewGatewayCommand(), gateway.NewGatewayCommand(),
status.NewStatusCommand(), status.NewStatusCommand(),
cron.NewCronCommand(), cron.NewCronCommand(),
jules.NewJulesCommand(),
migrate.NewMigrateCommand(), migrate.NewMigrateCommand(),
skills.NewSkillsCommand(), skills.NewSkillsCommand(),
version.NewVersionCommand(), version.NewVersionCommand(),

View file

@ -38,6 +38,7 @@ func TestNewPicoclawCommand(t *testing.T) {
"auth", "auth",
"cron", "cron",
"gateway", "gateway",
"jules",
"migrate", "migrate",
"onboard", "onboard",
"skills", "skills",

View file

@ -404,6 +404,12 @@ func DefaultConfig() *Config {
}, },
ExecTimeoutMinutes: 5, ExecTimeoutMinutes: 5,
}, },
Jules: JulesConfig{
ToolConfig: ToolConfig{
Enabled: true,
},
APIKey: "",
},
Exec: ExecConfig{ Exec: ExecConfig{
ToolConfig: ToolConfig{ ToolConfig: ToolConfig{
Enabled: true, Enabled: true,

506
pkg/config/defaults.go.orig Normal file
View file

@ -0,0 +1,506 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package config
import (
"os"
"path/filepath"
)
// DefaultConfig returns the default configuration for PicoClaw.
func DefaultConfig() *Config {
// Determine the base path for the workspace.
// Priority: $PICOCLAW_HOME > ~/.picoclaw
var homePath string
if picoclawHome := os.Getenv("PICOCLAW_HOME"); picoclawHome != "" {
homePath = picoclawHome
} else {
userHome, _ := os.UserHomeDir()
homePath = filepath.Join(userHome, ".picoclaw")
}
workspacePath := filepath.Join(homePath, "workspace")
return &Config{
Agents: AgentsConfig{
Defaults: AgentDefaults{
Workspace: workspacePath,
RestrictToWorkspace: true,
Provider: "",
Model: "",
MaxTokens: 32768,
Temperature: nil, // nil means use provider default
MaxToolIterations: 50,
SummarizeMessageThreshold: 20,
SummarizeTokenPercent: 75,
},
List: []AgentConfig{
{
ID: "the-clinician",
Name: "Medical Persona",
Workspace: filepath.Join(homePath, "Obsidian_Vault", "Patients"),
},
},
},
Bindings: []AgentBinding{},
Session: SessionConfig{
DMScope: "per-channel-peer",
},
Channels: ChannelsConfig{
WhatsApp: WhatsAppConfig{
Enabled: false,
BridgeURL: "ws://localhost:3001",
UseNative: false,
SessionStorePath: "",
AllowFrom: FlexibleStringSlice{},
},
Telegram: TelegramConfig{
Enabled: false,
Token: "",
AllowFrom: FlexibleStringSlice{},
Typing: TypingConfig{Enabled: true},
Placeholder: PlaceholderConfig{
Enabled: true,
Text: "Thinking... 💭",
},
},
Discord: DiscordConfig{
Enabled: false,
Token: "",
AllowFrom: FlexibleStringSlice{},
MentionOnly: false,
},
MaixCam: MaixCamConfig{
Enabled: false,
Host: "0.0.0.0",
Port: 18790,
AllowFrom: FlexibleStringSlice{},
},
QQ: QQConfig{
Enabled: false,
AppID: "",
AppSecret: "",
AllowFrom: FlexibleStringSlice{},
MaxMessageLength: 2000,
},
DingTalk: DingTalkConfig{
Enabled: false,
ClientID: "",
ClientSecret: "",
AllowFrom: FlexibleStringSlice{},
},
Slack: SlackConfig{
Enabled: false,
BotToken: "",
AppToken: "",
AllowFrom: FlexibleStringSlice{},
},
Matrix: MatrixConfig{
Enabled: false,
Homeserver: "https://matrix.org",
UserID: "",
AccessToken: "",
DeviceID: "",
JoinOnInvite: true,
AllowFrom: FlexibleStringSlice{},
GroupTrigger: GroupTriggerConfig{
MentionOnly: true,
},
Placeholder: PlaceholderConfig{
Enabled: true,
Text: "Thinking... 💭",
},
},
LINE: LINEConfig{
Enabled: false,
ChannelSecret: "",
ChannelAccessToken: "",
WebhookHost: "0.0.0.0",
WebhookPort: 18791,
WebhookPath: "/webhook/line",
AllowFrom: FlexibleStringSlice{},
GroupTrigger: GroupTriggerConfig{MentionOnly: true},
},
OneBot: OneBotConfig{
Enabled: false,
WSUrl: "ws://127.0.0.1:3001",
AccessToken: "",
ReconnectInterval: 5,
GroupTriggerPrefix: []string{},
AllowFrom: FlexibleStringSlice{},
},
Pico: PicoConfig{
Enabled: false,
Token: "",
PingInterval: 30,
ReadTimeout: 60,
WriteTimeout: 10,
MaxConnections: 100,
AllowFrom: FlexibleStringSlice{},
},
},
Providers: ProvidersConfig{
OpenAI: OpenAIProviderConfig{WebSearch: true},
},
ModelList: []ModelConfig{
// ============================================
// Add your API key to the model you want to use
// ============================================
// Zhipu AI (智谱) - https://open.bigmodel.cn/usercenter/apikeys
{
ModelName: "glm-4.7",
Model: "zhipu/glm-4.7",
APIBase: "https://open.bigmodel.cn/api/paas/v4",
APIKey: "",
},
// OpenAI - https://platform.openai.com/api-keys
{
ModelName: "gpt-5.4",
Model: "openai/gpt-5.4",
APIBase: "https://api.openai.com/v1",
APIKey: "",
},
// Anthropic Claude - https://console.anthropic.com/settings/keys
{
ModelName: "claude-sonnet-4.6",
Model: "anthropic/claude-sonnet-4.6",
APIBase: "https://api.anthropic.com/v1",
APIKey: "",
},
// DeepSeek - https://platform.deepseek.com/
{
ModelName: "deepseek-chat",
Model: "deepseek/deepseek-chat",
APIBase: "https://api.deepseek.com/v1",
APIKey: "",
},
// Google Gemini - https://ai.google.dev/
{
ModelName: "gemini-2.0-flash",
Model: "gemini/gemini-2.0-flash-exp",
APIBase: "https://generativelanguage.googleapis.com/v1beta",
APIKey: "",
},
// Qwen (通义千问) - https://dashscope.console.aliyun.com/apiKey
{
ModelName: "qwen-plus",
Model: "qwen/qwen-plus",
APIBase: "https://dashscope.aliyuncs.com/compatible-mode/v1",
APIKey: "",
},
// Moonshot (月之暗面) - https://platform.moonshot.cn/console/api-keys
{
ModelName: "moonshot-v1-8k",
Model: "moonshot/moonshot-v1-8k",
APIBase: "https://api.moonshot.cn/v1",
APIKey: "",
},
// Groq - https://console.groq.com/keys
{
ModelName: "llama-3.3-70b",
Model: "groq/llama-3.3-70b-versatile",
APIBase: "https://api.groq.com/openai/v1",
APIKey: "",
},
// OpenRouter (100+ models) - https://openrouter.ai/keys
{
ModelName: "openrouter-auto",
Model: "openrouter/auto",
APIBase: "https://openrouter.ai/api/v1",
APIKey: "",
},
{
ModelName: "openrouter-gpt-5.4",
Model: "openrouter/openai/gpt-5.4",
APIBase: "https://openrouter.ai/api/v1",
APIKey: "",
},
// NVIDIA - https://build.nvidia.com/
{
ModelName: "nemotron-4-340b",
Model: "nvidia/nemotron-4-340b-instruct",
APIBase: "https://integrate.api.nvidia.com/v1",
APIKey: "",
},
// Cerebras - https://inference.cerebras.ai/
{
ModelName: "cerebras-llama-3.3-70b",
Model: "cerebras/llama-3.3-70b",
APIBase: "https://api.cerebras.ai/v1",
APIKey: "",
},
// Vivgrid - https://vivgrid.com
{
ModelName: "vivgrid-auto",
Model: "vivgrid/auto",
APIBase: "https://api.vivgrid.com/v1",
APIKey: "",
},
// Volcengine (火山引擎) - https://console.volcengine.com/ark
{
ModelName: "ark-code-latest",
Model: "volcengine/ark-code-latest",
APIBase: "https://ark.cn-beijing.volces.com/api/v3",
APIKey: "",
},
{
ModelName: "doubao-pro",
Model: "volcengine/doubao-pro-32k",
APIBase: "https://ark.cn-beijing.volces.com/api/v3",
APIKey: "",
},
// ShengsuanYun (神算云)
{
ModelName: "deepseek-v3",
Model: "shengsuanyun/deepseek-v3",
APIBase: "https://api.shengsuanyun.com/v1",
APIKey: "",
},
// Antigravity (Google Cloud Code Assist) - OAuth only
{
ModelName: "gemini-flash",
Model: "antigravity/gemini-3-flash",
AuthMethod: "oauth",
},
// GitHub Copilot - https://github.com/settings/tokens
{
ModelName: "copilot-gpt-5.4",
Model: "github-copilot/gpt-5.4",
APIBase: "http://localhost:4321",
AuthMethod: "oauth",
},
// Ollama (local) - https://ollama.com
{
ModelName: "llama3",
Model: "ollama/llama3",
APIBase: "http://localhost:11434/v1",
APIKey: "ollama",
},
// Mistral AI - https://console.mistral.ai/api-keys
{
ModelName: "mistral-small",
Model: "mistral/mistral-small-latest",
APIBase: "https://api.mistral.ai/v1",
APIKey: "",
},
// Avian - https://avian.io
{
ModelName: "deepseek-v3.2",
Model: "avian/deepseek/deepseek-v3.2",
APIBase: "https://api.avian.io/v1",
APIKey: "",
},
{
ModelName: "kimi-k2.5",
Model: "avian/moonshotai/kimi-k2.5",
APIBase: "https://api.avian.io/v1",
APIKey: "",
},
// Minimax - https://api.minimaxi.com/
{
ModelName: "MiniMax-M2.5",
Model: "minimax/MiniMax-M2.5",
APIBase: "https://api.minimaxi.com/v1",
APIKey: "",
},
// LongCat - https://longcat.chat/platform
{
ModelName: "LongCat-Flash-Thinking",
Model: "longcat/LongCat-Flash-Thinking",
APIBase: "https://api.longcat.chat/openai",
APIKey: "",
},
// VLLM (local) - http://localhost:8000
{
ModelName: "local-model",
Model: "vllm/custom-model",
APIBase: "http://localhost:8000/v1",
APIKey: "",
},
},
Logger: LoggerConfig{
TimeFormat: "15:04:05",
},
Gateway: GatewayConfig{
Host: "127.0.0.1",
Port: 18790,
},
Tools: ToolsConfig{
MediaCleanup: MediaCleanupConfig{
ToolConfig: ToolConfig{
Enabled: true,
},
MaxAge: 30,
Interval: 5,
},
Web: WebToolsConfig{
ToolConfig: ToolConfig{
Enabled: true,
},
Proxy: "",
FetchLimitBytes: 10 * 1024 * 1024, // 10MB by default
Brave: BraveConfig{
Enabled: false,
APIKey: "",
APIKeys: nil,
MaxResults: 5,
},
Tavily: TavilyConfig{
Enabled: false,
APIKey: "",
APIKeys: nil,
MaxResults: 5,
},
DuckDuckGo: DuckDuckGoConfig{
Enabled: true,
MaxResults: 5,
},
Perplexity: PerplexityConfig{
Enabled: false,
APIKey: "",
APIKeys: nil,
MaxResults: 5,
},
SearXNG: SearXNGConfig{
Enabled: false,
BaseURL: "",
MaxResults: 5,
},
GLMSearch: GLMSearchConfig{
Enabled: false,
APIKey: "",
BaseURL: "https://open.bigmodel.cn/api/paas/v4/web_search",
SearchEngine: "search_std",
MaxResults: 5,
},
},
Cron: CronToolsConfig{
ToolConfig: ToolConfig{
Enabled: true,
},
ExecTimeoutMinutes: 5,
},
Exec: ExecConfig{
ToolConfig: ToolConfig{
Enabled: true,
},
EnableDenyPatterns: true,
AllowRemote: true,
TimeoutSeconds: 60,
},
Skills: SkillsToolsConfig{
ToolConfig: ToolConfig{
Enabled: true,
},
Registries: SkillsRegistriesConfig{
ClawHub: ClawHubRegistryConfig{
Enabled: true,
BaseURL: "https://clawhub.ai",
},
},
MaxConcurrentSearches: 2,
SearchCache: SearchCacheConfig{
MaxSize: 50,
TTLSeconds: 300,
},
},
SendFile: ToolConfig{
Enabled: true,
},
MCP: MCPConfig{
ToolConfig: ToolConfig{
Enabled: false,
},
Discovery: ToolDiscoveryConfig{
Enabled: false,
TTL: 5,
MaxSearchResults: 5,
UseBM25: true,
UseRegex: false,
},
Servers: map[string]MCPServerConfig{},
},
AppendFile: ToolConfig{
Enabled: true,
},
EditFile: ToolConfig{
Enabled: true,
},
FindSkills: ToolConfig{
Enabled: true,
},
I2C: ToolConfig{
Enabled: false, // Hardware tool - Linux only
},
InstallSkill: ToolConfig{
Enabled: true,
},
ListDir: ToolConfig{
Enabled: true,
},
Message: ToolConfig{
Enabled: true,
},
ReadFile: ReadFileToolConfig{
Enabled: true,
MaxReadFileSize: 64 * 1024, // 64KB
},
Spawn: ToolConfig{
Enabled: true,
},
SPI: ToolConfig{
Enabled: false, // Hardware tool - Linux only
},
Subagent: ToolConfig{
Enabled: true,
},
WebFetch: ToolConfig{
Enabled: true,
},
WriteFile: ToolConfig{
Enabled: true,
},
},
Heartbeat: HeartbeatConfig{
Enabled: true,
Interval: 30,
},
Devices: DevicesConfig{
Enabled: false,
MonitorUSB: true,
},
Voice: VoiceConfig{
EchoTranscription: false,
},
BuildInfo: BuildInfo{
Version: Version,
GitCommit: GitCommit,
BuildTime: BuildTime,
GoVersion: GoVersion,
},
}
}

View file

@ -96,6 +96,11 @@ type MediaCleanupConfig struct {
Interval int ` env:"PICOCLAW_MEDIA_CLEANUP_INTERVAL" json:"interval_minutes"` Interval int ` env:"PICOCLAW_MEDIA_CLEANUP_INTERVAL" json:"interval_minutes"`
} }
type JulesConfig struct {
ToolConfig `envPrefix:"PICOCLAW_TOOLS_JULES_"`
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_JULES_API_KEY"`
}
type ReadFileToolConfig struct { type ReadFileToolConfig struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
MaxReadFileSize int `json:"max_read_file_size"` MaxReadFileSize int `json:"max_read_file_size"`
@ -106,6 +111,7 @@ type ToolsConfig struct {
AllowReadPaths []string `json:"allow_read_paths" env:"PICOCLAW_TOOLS_ALLOW_READ_PATHS"` AllowReadPaths []string `json:"allow_read_paths" env:"PICOCLAW_TOOLS_ALLOW_READ_PATHS"`
AllowWritePaths []string `json:"allow_write_paths" env:"PICOCLAW_TOOLS_ALLOW_WRITE_PATHS"` AllowWritePaths []string `json:"allow_write_paths" env:"PICOCLAW_TOOLS_ALLOW_WRITE_PATHS"`
Web WebToolsConfig `json:"web"` Web WebToolsConfig `json:"web"`
Jules JulesConfig `json:"jules"`
Cron CronToolsConfig `json:"cron"` Cron CronToolsConfig `json:"cron"`
Exec ExecConfig `json:"exec"` Exec ExecConfig `json:"exec"`
Skills SkillsToolsConfig `json:"skills"` Skills SkillsToolsConfig `json:"skills"`
@ -182,6 +188,8 @@ func (t *ToolsConfig) IsToolEnabled(name string) bool {
switch name { switch name {
case "web": case "web":
return t.Web.Enabled return t.Web.Enabled
case "jules":
return t.Jules.Enabled
case "cron": case "cron":
return t.Cron.Enabled return t.Cron.Enabled
case "exec": case "exec":

236
pkg/config/tools.go.orig Normal file
View file

@ -0,0 +1,236 @@
package config
type ToolDiscoveryConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_DISCOVERY_ENABLED"`
TTL int `json:"ttl" env:"PICOCLAW_TOOLS_DISCOVERY_TTL"`
MaxSearchResults int `json:"max_search_results" env:"PICOCLAW_MAX_SEARCH_RESULTS"`
UseBM25 bool `json:"use_bm25" env:"PICOCLAW_TOOLS_DISCOVERY_USE_BM25"`
UseRegex bool `json:"use_regex" env:"PICOCLAW_TOOLS_DISCOVERY_USE_REGEX"`
}
type ToolConfig struct {
Enabled bool `json:"enabled" env:"ENABLED"`
}
type BraveConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"`
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEY"`
APIKeys []string `json:"api_keys" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEYS"`
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BRAVE_MAX_RESULTS"`
}
type TavilyConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_TAVILY_ENABLED"`
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_TAVILY_API_KEY"`
APIKeys []string `json:"api_keys" env:"PICOCLAW_TOOLS_WEB_TAVILY_API_KEYS"`
BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_TAVILY_BASE_URL"`
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_TAVILY_MAX_RESULTS"`
}
type DuckDuckGoConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_ENABLED"`
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_MAX_RESULTS"`
}
type PerplexityConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_ENABLED"`
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEY"`
APIKeys []string `json:"api_keys" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEYS"`
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"`
}
type SearXNGConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_SEARXNG_ENABLED"`
BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_SEARXNG_BASE_URL"`
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_SEARXNG_MAX_RESULTS"`
}
type GLMSearchConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_GLM_ENABLED"`
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_GLM_API_KEY"`
BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_GLM_BASE_URL"`
// SearchEngine specifies the search backend: "search_std" (default),
// "search_pro", "search_pro_sogou", or "search_pro_quark".
SearchEngine string `json:"search_engine" env:"PICOCLAW_TOOLS_WEB_GLM_SEARCH_ENGINE"`
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_GLM_MAX_RESULTS"`
}
type WebToolsConfig struct {
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_WEB_"`
Brave BraveConfig ` json:"brave"`
Tavily TavilyConfig ` json:"tavily"`
DuckDuckGo DuckDuckGoConfig ` json:"duckduckgo"`
Perplexity PerplexityConfig ` json:"perplexity"`
SearXNG SearXNGConfig ` json:"searxng"`
GLMSearch GLMSearchConfig ` json:"glm_search"`
// Proxy is an optional proxy URL for web tools (http/https/socks5/socks5h).
// For authenticated proxies, prefer HTTP_PROXY/HTTPS_PROXY env vars instead of embedding credentials in config.
Proxy string `json:"proxy,omitempty" env:"PICOCLAW_TOOLS_WEB_PROXY"`
FetchLimitBytes int64 `json:"fetch_limit_bytes,omitempty" env:"PICOCLAW_TOOLS_WEB_FETCH_LIMIT_BYTES"`
}
type CronToolsConfig struct {
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_CRON_"`
ExecTimeoutMinutes int ` env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES" json:"exec_timeout_minutes"` // 0 means no timeout
}
type ExecConfig struct {
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_EXEC_"`
EnableDenyPatterns bool ` env:"PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS" json:"enable_deny_patterns"`
AllowRemote bool ` env:"PICOCLAW_TOOLS_EXEC_ALLOW_REMOTE" json:"allow_remote"`
CustomDenyPatterns []string ` env:"PICOCLAW_TOOLS_EXEC_CUSTOM_DENY_PATTERNS" json:"custom_deny_patterns"`
CustomAllowPatterns []string ` env:"PICOCLAW_TOOLS_EXEC_CUSTOM_ALLOW_PATTERNS" json:"custom_allow_patterns"`
TimeoutSeconds int ` env:"PICOCLAW_TOOLS_EXEC_TIMEOUT_SECONDS" json:"timeout_seconds"` // 0 means use default (60s)
}
type SkillsToolsConfig struct {
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_SKILLS_"`
Registries SkillsRegistriesConfig ` json:"registries"`
MaxConcurrentSearches int ` json:"max_concurrent_searches" env:"PICOCLAW_TOOLS_SKILLS_MAX_CONCURRENT_SEARCHES"`
SearchCache SearchCacheConfig ` json:"search_cache"`
}
type MediaCleanupConfig struct {
ToolConfig ` envPrefix:"PICOCLAW_MEDIA_CLEANUP_"`
MaxAge int ` env:"PICOCLAW_MEDIA_CLEANUP_MAX_AGE" json:"max_age_minutes"`
Interval int ` env:"PICOCLAW_MEDIA_CLEANUP_INTERVAL" json:"interval_minutes"`
}
type ReadFileToolConfig struct {
Enabled bool `json:"enabled"`
MaxReadFileSize int `json:"max_read_file_size"`
}
type ToolsConfig struct {
Alpaca AlpacaConfig `json:"alpaca,omitempty"`
AllowReadPaths []string `json:"allow_read_paths" env:"PICOCLAW_TOOLS_ALLOW_READ_PATHS"`
AllowWritePaths []string `json:"allow_write_paths" env:"PICOCLAW_TOOLS_ALLOW_WRITE_PATHS"`
Web WebToolsConfig `json:"web"`
Cron CronToolsConfig `json:"cron"`
Exec ExecConfig `json:"exec"`
Skills SkillsToolsConfig `json:"skills"`
MediaCleanup MediaCleanupConfig `json:"media_cleanup"`
MCP MCPConfig `json:"mcp"`
AppendFile ToolConfig `json:"append_file" envPrefix:"PICOCLAW_TOOLS_APPEND_FILE_"`
EditFile ToolConfig `json:"edit_file" envPrefix:"PICOCLAW_TOOLS_EDIT_FILE_"`
FindSkills ToolConfig `json:"find_skills" envPrefix:"PICOCLAW_TOOLS_FIND_SKILLS_"`
I2C ToolConfig `json:"i2c" envPrefix:"PICOCLAW_TOOLS_I2C_"`
InstallSkill ToolConfig `json:"install_skill" envPrefix:"PICOCLAW_TOOLS_INSTALL_SKILL_"`
ListDir ToolConfig `json:"list_dir" envPrefix:"PICOCLAW_TOOLS_LIST_DIR_"`
Message ToolConfig `json:"message" envPrefix:"PICOCLAW_TOOLS_MESSAGE_"`
ReadFile ReadFileToolConfig `json:"read_file" envPrefix:"PICOCLAW_TOOLS_READ_FILE_"`
SendFile ToolConfig `json:"send_file" envPrefix:"PICOCLAW_TOOLS_SEND_FILE_"`
Spawn ToolConfig `json:"spawn" envPrefix:"PICOCLAW_TOOLS_SPAWN_"`
SPI ToolConfig `json:"spi" envPrefix:"PICOCLAW_TOOLS_SPI_"`
Subagent ToolConfig `json:"subagent" envPrefix:"PICOCLAW_TOOLS_SUBAGENT_"`
WebFetch ToolConfig `json:"web_fetch" envPrefix:"PICOCLAW_TOOLS_WEB_FETCH_"`
WriteFile ToolConfig `json:"write_file" envPrefix:"PICOCLAW_TOOLS_WRITE_FILE_"`
BrowserAction ToolConfig `json:"browser_action" envPrefix:"PICOCLAW_TOOLS_BROWSER_ACTION_"`
GoEval ToolConfig `json:"go_eval" envPrefix:"PICOCLAW_TOOLS_GO_EVAL_"`
}
type SearchCacheConfig struct {
MaxSize int `json:"max_size" env:"PICOCLAW_SKILLS_SEARCH_CACHE_MAX_SIZE"`
TTLSeconds int `json:"ttl_seconds" env:"PICOCLAW_SKILLS_SEARCH_CACHE_TTL_SECONDS"`
}
type SkillsRegistriesConfig struct {
ClawHub ClawHubRegistryConfig `json:"clawhub"`
}
type ClawHubRegistryConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_ENABLED"`
BaseURL string `json:"base_url" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_BASE_URL"`
AuthToken string `json:"auth_token" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_AUTH_TOKEN"`
SearchPath string `json:"search_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_SEARCH_PATH"`
SkillsPath string `json:"skills_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_SKILLS_PATH"`
DownloadPath string `json:"download_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_DOWNLOAD_PATH"`
Timeout int `json:"timeout" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_TIMEOUT"`
MaxZipSize int `json:"max_zip_size" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_MAX_ZIP_SIZE"`
MaxResponseSize int `json:"max_response_size" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_MAX_RESPONSE_SIZE"`
}
// MCPServerConfig defines configuration for a single MCP server
type MCPServerConfig struct {
// Enabled indicates whether this MCP server is active
Enabled bool `json:"enabled"`
// Command is the executable to run (e.g., "npx", "python", "/path/to/server")
Command string `json:"command"`
// Args are the arguments to pass to the command
Args []string `json:"args,omitempty"`
// Env are environment variables to set for the server process (stdio only)
Env map[string]string `json:"env,omitempty"`
// EnvFile is the path to a file containing environment variables (stdio only)
EnvFile string `json:"env_file,omitempty"`
// Type is "stdio", "sse", or "http" (default: stdio if command is set, sse if url is set)
Type string `json:"type,omitempty"`
// URL is used for SSE/HTTP transport
URL string `json:"url,omitempty"`
// Headers are HTTP headers to send with requests (sse/http only)
Headers map[string]string `json:"headers,omitempty"`
}
// MCPConfig defines configuration for all MCP servers
type MCPConfig struct {
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_MCP_"`
Discovery ToolDiscoveryConfig ` json:"discovery"`
// Servers is a map of server name to server configuration
Servers map[string]MCPServerConfig `json:"servers,omitempty"`
}
func (t *ToolsConfig) IsToolEnabled(name string) bool {
switch name {
case "web":
return t.Web.Enabled
case "cron":
return t.Cron.Enabled
case "exec":
return t.Exec.Enabled
case "skills":
return t.Skills.Enabled
case "media_cleanup":
return t.MediaCleanup.Enabled
case "append_file":
return t.AppendFile.Enabled
case "edit_file":
return t.EditFile.Enabled
case "find_skills":
return t.FindSkills.Enabled
case "i2c":
return t.I2C.Enabled
case "install_skill":
return t.InstallSkill.Enabled
case "list_dir":
return t.ListDir.Enabled
case "message":
return t.Message.Enabled
case "read_file":
return t.ReadFile.Enabled
case "spawn":
return t.Spawn.Enabled
case "spi":
return t.SPI.Enabled
case "subagent":
return t.Subagent.Enabled
case "web_fetch":
return t.WebFetch.Enabled
case "browser_action":
return t.BrowserAction.Enabled
case "go_eval":
return t.GoEval.Enabled
case "send_file":
return t.SendFile.Enabled
case "write_file":
return t.WriteFile.Enabled
case "mcp":
return t.MCP.Enabled
default:
return true
}
}
type AlpacaConfig struct {
KeyID string `json:"key_id,omitempty"`
SecretKey string `json:"secret_key,omitempty"`
BaseURL string `json:"base_url,omitempty"`
}