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:
parent
7a6a973ef3
commit
9f819f3ab8
9 changed files with 407 additions and 4 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
2
go.mod
2
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
|
||||
|
|
|
|||
5
go.sum
5
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=
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
219
pkg/tools/github.go
Normal file
219
pkg/tools/github.go
Normal 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
35
pkg/tools/github_test.go
Normal 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
80
pkg/tools/system.go
Normal 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
40
pkg/tools/system_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue