forked from chroe/gitlink-cli
125 lines
3.0 KiB
Go
125 lines
3.0 KiB
Go
package workflow
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"time"
|
|
|
|
"github.com/gitlink-org/gitlink-cli/internal/config"
|
|
)
|
|
|
|
const anthropicBaseURL = "https://api.anthropic.com/v1/messages"
|
|
const defaultModel = "claude-sonnet-4-6"
|
|
|
|
// AIClient wraps the Anthropic Messages API for skill step execution.
|
|
type AIClient struct {
|
|
apiKey string
|
|
model string
|
|
http *http.Client
|
|
}
|
|
|
|
// AIRequest bundles the data needed for an AI skill step call.
|
|
type AIRequest struct {
|
|
SystemPrompt string
|
|
UserData string
|
|
}
|
|
|
|
// AIResponse is the parsed structured output from an AI skill step.
|
|
type AIResponse struct {
|
|
Analysis interface{} `json:"analysis"`
|
|
Actions []AIAction `json:"actions"`
|
|
}
|
|
|
|
// NewAIClient resolves the API key (env → config) and returns a client, or nil if unavailable.
|
|
func NewAIClient() *AIClient {
|
|
key := os.Getenv("ANTHROPIC_API_KEY")
|
|
if key == "" {
|
|
cfg, err := config.Load()
|
|
if err == nil {
|
|
key = cfg.AnthropicAPIKey
|
|
}
|
|
}
|
|
if key == "" {
|
|
return nil
|
|
}
|
|
return &AIClient{
|
|
apiKey: key,
|
|
model: defaultModel,
|
|
http: &http.Client{Timeout: 60 * time.Second},
|
|
}
|
|
}
|
|
|
|
// Analyze sends the skill prompt + upstream data to the Anthropic API and parses the response.
|
|
func (c *AIClient) Analyze(req *AIRequest) (*AIResponse, error) {
|
|
if c == nil {
|
|
return nil, fmt.Errorf("AI client not configured: set ANTHROPIC_API_KEY or configure anthropic_api_key")
|
|
}
|
|
|
|
body := map[string]interface{}{
|
|
"model": c.model,
|
|
"max_tokens": 4096,
|
|
"system": req.SystemPrompt,
|
|
"messages": []map[string]string{
|
|
{"role": "user", "content": req.UserData},
|
|
},
|
|
}
|
|
|
|
payload, err := json.Marshal(body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal request: %w", err)
|
|
}
|
|
|
|
httpReq, err := http.NewRequest("POST", anthropicBaseURL, bytes.NewReader(payload))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create request: %w", err)
|
|
}
|
|
httpReq.Header.Set("Content-Type", "application/json")
|
|
httpReq.Header.Set("x-api-key", c.apiKey)
|
|
httpReq.Header.Set("anthropic-version", "2023-06-01")
|
|
|
|
resp, err := c.http.Do(httpReq)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("API call: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
respBody, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read response: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode != 200 {
|
|
return nil, fmt.Errorf("Anthropic API returned %d: %s", resp.StatusCode, string(respBody))
|
|
}
|
|
|
|
var result struct {
|
|
Content []struct {
|
|
Text string `json:"text"`
|
|
} `json:"content"`
|
|
}
|
|
if err := json.Unmarshal(respBody, &result); err != nil {
|
|
return nil, fmt.Errorf("parse response: %w", err)
|
|
}
|
|
|
|
if len(result.Content) == 0 {
|
|
return nil, fmt.Errorf("empty response from Anthropic API")
|
|
}
|
|
|
|
text := result.Content[0].Text
|
|
var aiResp AIResponse
|
|
if err := json.Unmarshal([]byte(text), &aiResp); err != nil {
|
|
return nil, fmt.Errorf("parse AI JSON output: %w\nraw: %s", err, text)
|
|
}
|
|
|
|
return &aiResp, nil
|
|
}
|
|
|
|
// HasKey reports whether the AI client is configured.
|
|
func (c *AIClient) HasKey() bool {
|
|
return c != nil && c.apiKey != ""
|
|
}
|