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.
This commit is contained in:
Udbhav 2026-02-24 14:24:55 +05:30
parent 7a6a973ef3
commit 9f819f3ab8
9 changed files with 407 additions and 4 deletions

View file

@ -14,7 +14,9 @@
"enabled": false, "enabled": false,
"token": "YOUR_TELEGRAM_BOT_TOKEN", "token": "YOUR_TELEGRAM_BOT_TOKEN",
"proxy": "", "proxy": "",
"allow_from": ["YOUR_USER_ID"] "allow_from": [
"YOUR_USER_ID"
]
}, },
"discord": { "discord": {
"enabled": false, "enabled": false,
@ -80,7 +82,9 @@
"smtp_user": "your-email@gmail.com", "smtp_user": "your-email@gmail.com",
"smtp_password": "your-app-password", "smtp_password": "your-app-password",
"poll_interval": 30, "poll_interval": 30,
"allow_from": ["allowed-sender@example.com"] "allow_from": [
"allowed-sender@example.com"
]
} }
}, },
"providers": { "providers": {
@ -132,6 +136,10 @@
"api_key": "YOUR_BRAVE_API_KEY", "api_key": "YOUR_BRAVE_API_KEY",
"max_results": 5 "max_results": 5
} }
},
"github": {
"enabled": false,
"token": "YOUR_GITHUB_TOKEN"
} }
}, },
"heartbeat": { "heartbeat": {
@ -146,4 +154,4 @@
"host": "0.0.0.0", "host": "0.0.0.0",
"port": 18790 "port": 18790
} }
} }

2
go.mod
View file

@ -10,6 +10,7 @@ require (
github.com/chzyer/readline v1.5.1 github.com/chzyer/readline v1.5.1
github.com/emersion/go-imap v1.2.1 github.com/emersion/go-imap v1.2.1
github.com/emersion/go-message v0.18.2 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/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.3
github.com/larksuite/oapi-sdk-go/v3 v3.5.3 github.com/larksuite/oapi-sdk-go/v3 v3.5.3
@ -25,6 +26,7 @@ require (
require ( require (
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // 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 github.com/pmezard/go-difflib v1.0.0 // indirect
golang.org/x/text v0.34.0 // indirect golang.org/x/text v0.34.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect

5
go.sum
View file

@ -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.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.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.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.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.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.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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 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 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= 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= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=

View file

@ -106,6 +106,10 @@ func createToolRegistry(workspace string, restrict bool, cfg *config.Config, msg
registry.Register(tools.NewSendEmailTool(cfg.Channels.Email)) registry.Register(tools.NewSendEmailTool(cfg.Channels.Email))
registry.Register(tools.NewCheckMailTool(msgBus)) // Keep for background sync trigger if needed 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 return registry
} }

View file

@ -227,7 +227,13 @@ type WebToolsConfig struct {
} }
type ToolsConfig 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 { func DefaultConfig() *Config {
@ -351,6 +357,10 @@ func DefaultConfig() *Config {
MaxResults: 5, MaxResults: 5,
}, },
}, },
GitHub: GitHubConfig{
Enabled: false,
Token: "",
},
}, },
Heartbeat: HeartbeatConfig{ Heartbeat: HeartbeatConfig{
Enabled: true, Enabled: true,

219
pkg/tools/github.go Normal file
View file

@ -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(),
}
}

35
pkg/tools/github_test.go Normal file
View file

@ -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)
}
}

80
pkg/tools/system.go Normal file
View file

@ -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.",
}
}

40
pkg/tools/system_test.go Normal file
View file

@ -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")
}
}