package shared import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "os" "os/exec" "strings" "time" ) func RunProviderCommand( ctx context.Context, provider, model, prompt, workingDirectory string, ) (string, error) { command, args := ResolveProviderCommand( provider, model, prompt, workingDirectory, ) if command == "" { return "", fmt.Errorf("unsupported provider: %s", provider) } if err := ensureWorkingDirectoryExists(workingDirectory); err != nil { return "", err } cmd := exec.CommandContext(ctx, command, args...) if strings.TrimSpace(workingDirectory) != "" { cmd.Dir = strings.TrimSpace(workingDirectory) } var stdout bytes.Buffer var stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr if err := cmd.Run(); err != nil { if errors.Is(ctx.Err(), context.Canceled) { return "", errors.New("run canceled") } message := strings.TrimSpace(stderr.String()) if message == "" { message = err.Error() } return "", fmt.Errorf("%s run failed: %s", provider, message) } output := strings.TrimSpace(stdout.String()) if output == "" { output = strings.TrimSpace(stderr.String()) } if output == "" { return "", fmt.Errorf("%s returned empty output", provider) } return output, nil } func ensureWorkingDirectoryExists(workingDirectory string) error { workingDirectory = strings.TrimSpace(workingDirectory) if workingDirectory == "" { return nil } if info, err := os.Stat(workingDirectory); err == nil { if info.IsDir() { return nil } return fmt.Errorf("working directory is not a directory: %s", workingDirectory) } if err := os.MkdirAll(workingDirectory, 0o755); err != nil { return fmt.Errorf("ensure working directory %s: %w", workingDirectory, err) } info, err := os.Stat(workingDirectory) if err != nil { return fmt.Errorf("verify working directory %s: %w", workingDirectory, err) } if !info.IsDir() { return fmt.Errorf("working directory is not a directory: %s", workingDirectory) } return nil } func NormalizeProviderWorkingDirectory(provider, requested string) (string, string) { requested = strings.TrimSpace(requested) if requested == "" { return "", "" } switch strings.TrimSpace(strings.ToLower(provider)) { case "codex", "opencode": default: return requested, requested } if canAccessWorkingDirectory(requested) { return requested, requested } home, err := os.UserHomeDir() if err != nil { return requested, requested } home = strings.TrimSpace(home) if home == "" { return requested, requested } return home, home } func canAccessWorkingDirectory(path string) bool { info, err := os.Stat(path) if err != nil || !info.IsDir() { return false } cmd := exec.Command("pwd") cmd.Dir = path return cmd.Run() == nil } func ResolveProviderCommand( provider, model, prompt, cwd string, ) (string, []string) { switch strings.TrimSpace(strings.ToLower(provider)) { case "codex": binary := strings.TrimSpace(EnvOrDefault("ACP_CODEX_BIN", "codex")) args := []string{"exec", "--skip-git-repo-check", "--color", "never"} if strings.TrimSpace(cwd) != "" { args = append(args, "-C", strings.TrimSpace(cwd)) } if strings.TrimSpace(model) != "" { args = append(args, "-m", strings.TrimSpace(model)) } args = append(args, prompt) return binary, args case "opencode": binary := strings.TrimSpace(EnvOrDefault("ACP_OPENCODE_BIN", "opencode")) args := []string{"run", "--format", "default"} if strings.TrimSpace(cwd) != "" { args = append(args, "--dir", strings.TrimSpace(cwd)) } if strings.TrimSpace(model) != "" { args = append(args, "-m", strings.TrimSpace(model)) } args = append(args, prompt) return binary, args case "claude": binary := strings.TrimSpace(EnvOrDefault("ACP_CLAUDE_BIN", "claude")) if strings.TrimSpace(model) == "" { return binary, []string{"-p", prompt} } return binary, []string{ "--model", strings.TrimSpace(model), "-p", prompt, } case "gemini": binary := strings.TrimSpace(EnvOrDefault("ACP_GEMINI_BIN", "gemini")) if strings.TrimSpace(model) == "" { return binary, []string{"-p", prompt} } return binary, []string{ "--model", strings.TrimSpace(model), "-p", prompt, } default: return "", nil } } func AugmentPromptWithAttachments(prompt string, params map[string]any) string { attachmentsRaw := ListArg(params, "attachments") if len(attachmentsRaw) == 0 { return prompt } lines := make([]string, 0, len(attachmentsRaw)) for _, raw := range attachmentsRaw { entry, ok := raw.(map[string]any) if !ok { continue } name := strings.TrimSpace(StringArg(entry, "name", "attachment")) path := strings.TrimSpace(StringArg(entry, "path", "")) if path == "" { continue } lines = append(lines, fmt.Sprintf("- %s: %s", name, path)) } if len(lines) == 0 { return prompt } var builder strings.Builder builder.WriteString("User-selected local attachments:\n") builder.WriteString(strings.Join(lines, "\n")) builder.WriteString("\n\n") builder.WriteString(prompt) return builder.String() } func ComposeHistoryPrompt(history any) string { var builder strings.Builder switch h := history.(type) { case []string: if len(h) == 0 { return "" } turn := 1 for _, turnText := range h { if strings.HasPrefix(turnText, "ASSISTANT: ") { builder.WriteString("## Assistant Response\n") builder.WriteString(strings.TrimPrefix(turnText, "ASSISTANT: ")) } else if strings.HasPrefix(turnText, "USER: ") { _, _ = fmt.Fprintf(&builder, "## User Turn %d\n", turn) builder.WriteString(strings.TrimPrefix(turnText, "USER: ")) turn++ } else { // Default to User Turn if no prefix matches (for tests and legacy compatibility) _, _ = fmt.Fprintf(&builder, "## User Turn %d\n", turn) builder.WriteString(turnText) turn++ } builder.WriteString("\n\n") } case []map[string]string: if len(h) == 0 { return "" } turn := 1 for _, msg := range h { role := msg["role"] content := msg["content"] if role == "user" { _, _ = fmt.Fprintf(&builder, "## User Turn %d\n", turn) turn++ } else { builder.WriteString("## Assistant Response\n") } builder.WriteString(content) builder.WriteString("\n\n") } } return strings.TrimSpace(builder.String()) } func CallOpenAICompatibleCtx( ctx context.Context, baseURL, apiKey, model string, messages []map[string]string, ) (string, error) { payload := map[string]any{ "model": model, "messages": messages, "max_tokens": 4096, "stream": false, } body, _ := json.Marshal(payload) request, err := http.NewRequestWithContext( ctx, http.MethodPost, strings.TrimRight(baseURL, "/")+"/chat/completions", bytes.NewReader(body), ) if err != nil { return "", err } request.Header.Set("Content-Type", "application/json") request.Header.Set("Authorization", "Bearer "+apiKey) client := NewHTTPClient(120 * time.Second) response, err := client.Do(request) if err != nil { return "", err } defer func() { _ = response.Body.Close() }() responseBody, err := io.ReadAll(response.Body) if err != nil { return "", err } if response.StatusCode < 200 || response.StatusCode >= 300 { return "", fmt.Errorf( "api error %d: %s", response.StatusCode, strings.TrimSpace(string(responseBody)), ) } var decoded map[string]any if err := json.Unmarshal(responseBody, &decoded); err != nil { return "", err } choices, _ := decoded["choices"].([]any) if len(choices) == 0 { return "", errors.New("missing choices in response") } choice, _ := choices[0].(map[string]any) message, _ := choice["message"].(map[string]any) content := strings.TrimSpace(fmt.Sprint(message["content"])) if content == "" || content == "" { return "", errors.New("empty response content") } return content, nil } func HandleChatTool(arguments map[string]any) (string, error) { apiKey := strings.TrimSpace(EnvOrDefault("LLM_API_KEY", "")) if apiKey == "" { return "", errors.New("LLM_API_KEY environment variable not set") } baseURL := NormalizeBaseURL( EnvOrDefault("LLM_BASE_URL", "https://api.openai.com/v1"), ) model := StringArg(arguments, "model", EnvOrDefault("LLM_MODEL", "gpt-4o")) prompt := strings.TrimSpace(StringArg(arguments, "prompt", "")) if prompt == "" { return "", errors.New("prompt is required") } system := strings.TrimSpace(StringArg(arguments, "system", "")) messages := make([]map[string]string, 0, 2) if system != "" { messages = append(messages, map[string]string{ "role": "system", "content": system, }) } messages = append(messages, map[string]string{ "role": "user", "content": prompt, }) return CallOpenAICompatible(baseURL, apiKey, model, messages) } func CallOpenAICompatible( baseURL, apiKey, model string, messages []map[string]string, ) (string, error) { return CallOpenAICompatibleCtx( context.Background(), baseURL, apiKey, model, messages, ) } func RunClaudeReview( prompt, model, system, tools string, timeout time.Duration, ) (string, error) { claudeBin := strings.TrimSpace(EnvOrDefault("CLAUDE_BIN", "claude")) resolved, err := exec.LookPath(claudeBin) if err != nil { return "", fmt.Errorf("claude CLI not found: %s", claudeBin) } args := []string{ "-p", prompt, "--output-format", "json", "--permission-mode", "plan", } if model != "" { args = append(args, "--model", model) } if system != "" { args = append(args, "--system-prompt", system) } if tools != "" { args = append(args, "--tools", tools) } ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() cmd := exec.CommandContext(ctx, resolved, args...) cmd.Stdin = nil var stdout bytes.Buffer var stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr if err := cmd.Run(); err != nil { if errors.Is(ctx.Err(), context.DeadlineExceeded) { return "", fmt.Errorf("claude review timed out after %s", timeout) } message := strings.TrimSpace(stderr.String()) if message == "" { message = err.Error() } return "", fmt.Errorf("claude review failed: %s", message) } payload, err := ParseClaudeJSON(stdout.String()) if err != nil { message := strings.TrimSpace(stderr.String()) if message != "" { return "", fmt.Errorf("%v. stderr: %s", err, message) } return "", err } if isError, _ := payload["is_error"].(bool); isError { return "", fmt.Errorf("%v", payload["result"]) } response := strings.TrimSpace(fmt.Sprint(payload["result"])) if response == "" || response == "" { return "", errors.New("claude review returned empty output") } return response, nil } func ParseClaudeJSON(raw string) (map[string]any, error) { lines := strings.Split(raw, "\n") for i := len(lines) - 1; i >= 0; i-- { candidate := strings.TrimSpace(lines[i]) if candidate == "" { continue } var payload map[string]any if err := json.Unmarshal([]byte(candidate), &payload); err == nil { return payload, nil } } return nil, errors.New("claude CLI did not return JSON output") }