From 9f819f3ab84ad3745475b1b7ff643c36e7839a69 Mon Sep 17 00:00:00 2001 From: Udbhav Date: Tue, 24 Feb 2026 14:24:55 +0530 Subject: [PATCH] feat: Add GitHub and System Tools Extracted from original PR to split into atomic units. Replaces gopsutil with standard library/native OS commands to save binary size. --- config/config.example.json | 14 ++- go.mod | 2 + go.sum | 5 + pkg/agent/loop.go | 4 + pkg/config/config.go | 12 +- pkg/tools/github.go | 219 +++++++++++++++++++++++++++++++++++++ pkg/tools/github_test.go | 35 ++++++ pkg/tools/system.go | 80 ++++++++++++++ pkg/tools/system_test.go | 40 +++++++ 9 files changed, 407 insertions(+), 4 deletions(-) create mode 100644 pkg/tools/github.go create mode 100644 pkg/tools/github_test.go create mode 100644 pkg/tools/system.go create mode 100644 pkg/tools/system_test.go diff --git a/config/config.example.json b/config/config.example.json index a7139ccf4..5ba814baa 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -14,7 +14,9 @@ "enabled": false, "token": "YOUR_TELEGRAM_BOT_TOKEN", "proxy": "", - "allow_from": ["YOUR_USER_ID"] + "allow_from": [ + "YOUR_USER_ID" + ] }, "discord": { "enabled": false, @@ -80,7 +82,9 @@ "smtp_user": "your-email@gmail.com", "smtp_password": "your-app-password", "poll_interval": 30, - "allow_from": ["allowed-sender@example.com"] + "allow_from": [ + "allowed-sender@example.com" + ] } }, "providers": { @@ -132,6 +136,10 @@ "api_key": "YOUR_BRAVE_API_KEY", "max_results": 5 } + }, + "github": { + "enabled": false, + "token": "YOUR_GITHUB_TOKEN" } }, "heartbeat": { @@ -146,4 +154,4 @@ "host": "0.0.0.0", "port": 18790 } -} +} \ No newline at end of file diff --git a/go.mod b/go.mod index f4d9d89c3..05743d575 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/chzyer/readline v1.5.1 github.com/emersion/go-imap v1.2.1 github.com/emersion/go-message v0.18.2 + github.com/google/go-github/v60 v60.0.0 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 github.com/larksuite/oapi-sdk-go/v3 v3.5.3 @@ -25,6 +26,7 @@ require ( require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect + github.com/google/go-querystring v1.1.0 // indirect github.com/pmezard/go-difflib v1.0.0 // 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 2d28274de..243566f2d 100644 --- a/go.sum +++ b/go.sum @@ -64,11 +64,16 @@ github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiu github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-github/v60 v60.0.0 h1:oLG98PsLauFvvu4D/YPxq374jhSxFYdzQGNCyONLfn8= +github.com/google/go-github/v60 v60.0.0/go.mod h1:ByhX2dP9XT9o/ll2yXAu2VD8l5eNVg8hD4Cr0S/LmQk= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 06c9925cb..38f9ecfe4 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -106,6 +106,10 @@ func createToolRegistry(workspace string, restrict bool, cfg *config.Config, msg registry.Register(tools.NewSendEmailTool(cfg.Channels.Email)) registry.Register(tools.NewCheckMailTool(msgBus)) // Keep for background sync trigger if needed + // Developer tools + registry.Register(tools.NewGitHubTool(cfg.Tools.GitHub)) + registry.Register(tools.NewSystemTool()) + return registry } diff --git a/pkg/config/config.go b/pkg/config/config.go index 15b1a3dbb..70f1152cf 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -227,7 +227,13 @@ type WebToolsConfig struct { } type ToolsConfig struct { - Web WebToolsConfig `json:"web"` + Web WebToolsConfig `json:"web"` + GitHub GitHubConfig `json:"github"` +} + +type GitHubConfig struct { + Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_GITHUB_ENABLED"` + Token string `json:"token" env:"PICOCLAW_TOOLS_GITHUB_TOKEN"` } func DefaultConfig() *Config { @@ -351,6 +357,10 @@ func DefaultConfig() *Config { MaxResults: 5, }, }, + GitHub: GitHubConfig{ + Enabled: false, + Token: "", + }, }, Heartbeat: HeartbeatConfig{ Enabled: true, diff --git a/pkg/tools/github.go b/pkg/tools/github.go new file mode 100644 index 000000000..ca54540af --- /dev/null +++ b/pkg/tools/github.go @@ -0,0 +1,219 @@ +package tools + +import ( + "context" + "fmt" + "strings" + + "github.com/google/go-github/v60/github" + "github.com/sipeed/picoclaw/pkg/config" +) + +type GitHubTool struct { + config config.GitHubConfig + client *github.Client +} + +func NewGitHubTool(cfg config.GitHubConfig) *GitHubTool { + t := &GitHubTool{ + config: cfg, + } + if cfg.Enabled { + if cfg.Token != "" { + t.client = github.NewClient(nil).WithAuthToken(cfg.Token) + } else { + t.client = github.NewClient(nil) + } + } + return t +} + +func (t *GitHubTool) Name() string { + return "github" +} + +func (t *GitHubTool) Description() string { + return "Interact with GitHub to list issues, pull requests, or read files from repositories." +} + +func (t *GitHubTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "action": map[string]interface{}{ + "type": "string", + "description": "Action to perform: list_issues, get_pr, read_file, list_repos", + "enum": []string{"list_issues", "get_pr", "read_file", "list_repos"}, + }, + "owner": map[string]interface{}{ + "type": "string", + "description": "Repository owner (user or organization)", + }, + "repo": map[string]interface{}{ + "type": "string", + "description": "Repository name", + }, + "number": map[string]interface{}{ + "type": "integer", + "description": "Issue or PR number (required for get_pr)", + }, + "path": map[string]interface{}{ + "type": "string", + "description": "File path in repository (required for read_file)", + }, + "count": map[string]interface{}{ + "type": "integer", + "description": "Number of items to list (default: 5, max: 20)", + }, + }, + "required": []string{"action"}, + } +} + +func (t *GitHubTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult { + if !t.config.Enabled { + return ErrorResult("GitHub tool is not enabled in configuration.") + } + if t.client == nil { + return ErrorResult("GitHub client not initialized.") + } + + action, _ := args["action"].(string) + owner, _ := args["owner"].(string) + repo, _ := args["repo"].(string) + + // Defaults + count := 5 + if c, ok := args["count"].(float64); ok { + count = int(c) + } + if count > 20 { + count = 20 + } + + switch action { + case "list_issues": + if owner == "" || repo == "" { + return ErrorResult("Owner and repo are required for list_issues.") + } + return t.listIssues(ctx, owner, repo, count) + case "get_pr": + if owner == "" || repo == "" { + return ErrorResult("Owner and repo are required for get_pr.") + } + number, ok := args["number"].(float64) + if !ok { + return ErrorResult("Number is required for get_pr.") + } + return t.getPR(ctx, owner, repo, int(number)) + case "read_file": + if owner == "" || repo == "" { + return ErrorResult("Owner and repo are required for read_file.") + } + path, _ := args["path"].(string) + if path == "" { + return ErrorResult("Path is required for read_file.") + } + return t.readFile(ctx, owner, repo, path) + case "list_repos": + // If owner is provided, list user's repos, else authenticated user's repos + return t.listRepos(ctx, owner, count) + default: + return ErrorResult(fmt.Sprintf("Unknown action: %s", action)) + } +} + +func (t *GitHubTool) listIssues(ctx context.Context, owner, repo string, count int) *ToolResult { + opts := &github.IssueListByRepoOptions{ + State: "open", + ListOptions: github.ListOptions{PerPage: count}, + } + issues, _, err := t.client.Issues.ListByRepo(ctx, owner, repo, opts) + if err != nil { + return ErrorResult(fmt.Sprintf("Failed to list issues: %v", err)) + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("Open issues in %s/%s:\n", owner, repo)) + for _, issue := range issues { + sb.WriteString(fmt.Sprintf("- #%d: %s (by %s)\n", issue.GetNumber(), issue.GetTitle(), issue.User.GetLogin())) + } + + return &ToolResult{ + ForLLM: sb.String(), + ForUser: sb.String(), + } +} + +func (t *GitHubTool) getPR(ctx context.Context, owner, repo string, number int) *ToolResult { + pr, _, err := t.client.PullRequests.Get(ctx, owner, repo, number) + if err != nil { + return ErrorResult(fmt.Sprintf("Failed to get PR #%d: %v", number, err)) + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("PR #%d: %s\n", pr.GetNumber(), pr.GetTitle())) + sb.WriteString(fmt.Sprintf("State: %s\n", pr.GetState())) + sb.WriteString(fmt.Sprintf("User: %s\n", pr.User.GetLogin())) + if pr.Body != nil { + sb.WriteString(fmt.Sprintf("\nBody:\n%s\n", *pr.Body)) + } + + return &ToolResult{ + ForLLM: sb.String(), + ForUser: sb.String(), + } +} + +func (t *GitHubTool) readFile(ctx context.Context, owner, repo, path string) *ToolResult { + content, _, _, err := t.client.Repositories.GetContents(ctx, owner, repo, path, nil) + if err != nil { + return ErrorResult(fmt.Sprintf("Failed to read file: %v", err)) + } + + decoded, err := content.GetContent() + if err != nil { + return ErrorResult(fmt.Sprintf("Failed to decode file content: %v", err)) + } + + return &ToolResult{ + ForLLM: decoded, + ForUser: fmt.Sprintf("Read file %s from %s/%s", path, owner, repo), + } +} + +func (t *GitHubTool) listRepos(ctx context.Context, user string, count int) *ToolResult { + opts := &github.RepositoryListOptions{ + ListOptions: github.ListOptions{PerPage: count}, + Sort: "updated", + } + var repos []*github.Repository + var err error + + if user != "" { + repos, _, err = t.client.Repositories.List(ctx, user, opts) + } else { + // Authenticated user + repos, _, err = t.client.Repositories.List(ctx, "", opts) + } + + if err != nil { + return ErrorResult(fmt.Sprintf("Failed to list repos: %v", err)) + } + + var sb strings.Builder + if user != "" { + sb.WriteString(fmt.Sprintf("Repositories for %s:\n", user)) + } else { + sb.WriteString("Your repositories:\n") + } + + for _, repo := range repos { + sb.WriteString(fmt.Sprintf("- %s: %s (⭐ %d)\n", repo.GetName(), repo.GetDescription(), repo.GetStargazersCount())) + } + + return &ToolResult{ + ForLLM: sb.String(), + ForUser: sb.String(), + } +} diff --git a/pkg/tools/github_test.go b/pkg/tools/github_test.go new file mode 100644 index 000000000..ab3efab8c --- /dev/null +++ b/pkg/tools/github_test.go @@ -0,0 +1,35 @@ +package tools + +import ( + "context" + "strings" + "testing" + + "github.com/sipeed/picoclaw/pkg/config" +) + +func TestGitHubTool_Metadata(t *testing.T) { + tool := NewGitHubTool(config.GitHubConfig{}) + + if tool.Name() != "github" { + t.Errorf("Expected name 'github', got '%s'", tool.Name()) + } + + if tool.Description() == "" { + t.Error("Expected tool to have a description") + } + + params := tool.Parameters() + if params == nil { + t.Error("Expected parameters to not be nil") + } +} + +func TestGitHubTool_Disabled(t *testing.T) { + tool := NewGitHubTool(config.GitHubConfig{Enabled: false}) + res := tool.Execute(context.Background(), map[string]interface{}{"action": "user_info"}) + + if res.Err == nil && !strings.Contains(res.ForLLM, "not enabled") { + t.Errorf("Expected error when tool is disabled, got: %v", res.ForLLM) + } +} diff --git a/pkg/tools/system.go b/pkg/tools/system.go new file mode 100644 index 000000000..b645d7626 --- /dev/null +++ b/pkg/tools/system.go @@ -0,0 +1,80 @@ +package tools + +import ( + "context" + "fmt" + "os" + "os/exec" + "runtime" + "strings" +) + +type SystemTool struct{} + +func NewSystemTool() *SystemTool { + return &SystemTool{} +} + +func (t *SystemTool) Name() string { + return "system_stats" +} + +func (t *SystemTool) Description() string { + return "Get current system statistics including OS/Arch, Process Memory, Disk usage, and Uptime/Load." +} + +func (t *SystemTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{}, + "required": []string{}, + } +} + +func (t *SystemTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult { + var sb strings.Builder + + // Host Info + sb.WriteString(fmt.Sprintf("OS/Arch: %s/%s\n", runtime.GOOS, runtime.GOARCH)) + + hostname, err := os.Hostname() + if err == nil { + sb.WriteString(fmt.Sprintf("Hostname: %s\n", hostname)) + } + + // Memory (Process level since system-level memory is platform specific) + var memStats runtime.MemStats + runtime.ReadMemStats(&memStats) + allocGB := float64(memStats.Alloc) / 1024 / 1024 / 1024 + sysGB := float64(memStats.Sys) / 1024 / 1024 / 1024 + sb.WriteString(fmt.Sprintf("Process Memory: %.2f GB allocated / %.2f GB sys\n", allocGB, sysGB)) + + // OS-specific commands for System-level Disk/Uptime + if runtime.GOOS == "linux" || runtime.GOOS == "darwin" { + // Disk Usage + dfOut, err := exec.Command("df", "-h", "/").Output() + if err == nil { + lines := strings.Split(strings.TrimSpace(string(dfOut)), "\n") + if len(lines) > 1 { + fields := strings.Fields(lines[1]) + // usually: Filesystem Size Used Avail Capacity iused ifree %iused Mounted + if len(fields) >= 5 { + sb.WriteString(fmt.Sprintf("Disk (/): %s used / %s total (%s)\n", fields[2], fields[1], fields[4])) + } else { + sb.WriteString(fmt.Sprintf("Disk (/): %s\n", lines[1])) + } + } + } + + // Uptime & Load Avg + uptimeOut, err := exec.Command("uptime").Output() + if err == nil { + sb.WriteString(fmt.Sprintf("Uptime & Load: %s\n", strings.TrimSpace(string(uptimeOut)))) + } + } + + return &ToolResult{ + ForLLM: sb.String(), + ForUser: "System stats retrieved.", + } +} diff --git a/pkg/tools/system_test.go b/pkg/tools/system_test.go new file mode 100644 index 000000000..bbab7b097 --- /dev/null +++ b/pkg/tools/system_test.go @@ -0,0 +1,40 @@ +package tools + +import ( + "context" + "testing" +) + +func TestSystemTool_Metadata(t *testing.T) { + tool := NewSystemTool() + + if tool.Name() != "system_stats" { + t.Errorf("Expected name 'system_stats', got '%s'", tool.Name()) + } + + if tool.Description() == "" { + t.Error("Expected tool to have a description") + } + + params := tool.Parameters() + if params == nil { + t.Error("Expected parameters to not be nil") + } +} + +func TestSystemTool_Execute(t *testing.T) { + tool := NewSystemTool() + res := tool.Execute(context.Background(), nil) + + if res == nil { + t.Fatal("Expected ToolResult to not be nil") + } + + if res.Err != nil { + t.Errorf("Expected no error, got: %v", res.Err) + } + + if res.ForLLM == "" { + t.Error("Expected ForLLM to contain stats") + } +}