Files
sproutclaw-web/backend/internal/handlers/agenttools.go

180 lines
4.9 KiB
Go

package handlers
import (
"bytes"
"context"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
)
// AgentToolInfo holds version info for an AI coding agent tool.
type AgentToolInfo struct {
Name string `json:"name"`
Found bool `json:"found"`
Version string `json:"version,omitempty"`
Path string `json:"path,omitempty"`
}
// RuntimeInfo holds version info for a programming language runtime or compiler.
type RuntimeInfo struct {
Name string `json:"name"`
Found bool `json:"found"`
Version string `json:"version,omitempty"`
Path string `json:"path,omitempty"`
}
type agentToolDef struct {
Name string
Binaries []string
Args []string
}
type runtimeDef struct {
Name string
Binaries []string // try in order
Args []string
}
var agentToolDefs = []agentToolDef{
{Name: "Codex", Binaries: []string{"codex"}, Args: []string{"--version"}},
{Name: "Claude Code", Binaries: []string{"claude"}, Args: []string{"--version"}},
{Name: "OpenCode", Binaries: []string{"opencode"}, Args: []string{"--version"}},
}
var runtimeDefs = []runtimeDef{
// python3 first; fall back to python on Windows where python3 may not exist
{Name: "Python", Binaries: []string{"python3", "python"}, Args: []string{"--version"}},
{Name: "Node.js", Binaries: []string{"node"}, Args: []string{"--version"}},
{Name: "Bun", Binaries: []string{"bun"}, Args: []string{"--version"}},
{Name: "Go", Binaries: []string{"go"}, Args: []string{"version"}},
// Java outputs version to stderr — runVersionOutput handles this
{Name: "Java", Binaries: []string{"java"}, Args: []string{"-version"}},
{Name: "Rust", Binaries: []string{"rustc"}, Args: []string{"--version"}},
// C compilers: detect both, show whichever is present
{Name: "GCC", Binaries: []string{"gcc"}, Args: []string{"--version"}},
{Name: "Clang", Binaries: []string{"clang"}, Args: []string{"--version"}},
}
// extraSearchPaths returns common installation directories not always in PATH.
func extraSearchPaths() []string {
home, _ := os.UserHomeDir()
paths := []string{
filepath.Join(home, "bin"),
filepath.Join(home, ".local", "bin"),
filepath.Join(home, ".bun", "bin"),
filepath.Join(home, "go", "bin"),
filepath.Join(home, ".cargo", "bin"),
"/usr/local/go/bin",
"/usr/local/bin",
"/usr/bin",
}
if runtime.GOOS == "windows" {
appdata := os.Getenv("APPDATA")
paths = append(paths,
filepath.Join(appdata, "npm"),
filepath.Join(home, "AppData", "Local", "Programs"),
filepath.Join(home, "AppData", "Roaming", "npm"),
)
}
return paths
}
// lookupBinary searches PATH then extra directories for the binary.
func lookupBinary(name string) string {
if p, err := exec.LookPath(name); err == nil {
return p
}
for _, dir := range extraSearchPaths() {
candidate := filepath.Join(dir, name)
if runtime.GOOS == "windows" {
candidate += ".exe"
}
if _, err := os.Stat(candidate); err == nil {
return candidate
}
}
return ""
}
// runVersionOutput runs the binary and returns the first non-empty line from stdout
// or stderr (some tools like Java output version to stderr).
func runVersionOutput(binaryPath string, args []string) string {
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, binaryPath, args...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
_ = cmd.Run()
out := strings.TrimSpace(stdout.String())
if out == "" {
out = strings.TrimSpace(stderr.String())
}
if idx := strings.IndexByte(out, '\n'); idx >= 0 {
out = strings.TrimSpace(out[:idx])
}
return out
}
// runVersion is kept for backward compatibility.
func runVersion(binaryPath string, args []string) string {
return runVersionOutput(binaryPath, args)
}
// detectAgentTools detects all AI agent tools in parallel.
func detectAgentTools() []AgentToolInfo {
results := make([]AgentToolInfo, len(agentToolDefs))
var wg sync.WaitGroup
for i, def := range agentToolDefs {
wg.Add(1)
go func(idx int, d agentToolDef) {
defer wg.Done()
info := AgentToolInfo{Name: d.Name}
for _, bin := range d.Binaries {
p := lookupBinary(bin)
if p == "" {
continue
}
info.Found = true
info.Path = p
info.Version = runVersionOutput(p, d.Args)
break
}
results[idx] = info
}(i, def)
}
wg.Wait()
return results
}
// detectRuntimes detects all language runtimes in parallel.
func detectRuntimes() []RuntimeInfo {
results := make([]RuntimeInfo, len(runtimeDefs))
var wg sync.WaitGroup
for i, def := range runtimeDefs {
wg.Add(1)
go func(idx int, d runtimeDef) {
defer wg.Done()
info := RuntimeInfo{Name: d.Name}
for _, bin := range d.Binaries {
p := lookupBinary(bin)
if p == "" {
continue
}
info.Found = true
info.Path = p
info.Version = runVersionOutput(p, d.Args)
break
}
results[idx] = info
}(i, def)
}
wg.Wait()
return results
}