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 }