Skip to content

Commit c32a389

Browse files
committed
feat: add Anthropic provider integration with Claude Code CLI
- Add AnthropicProvider that executes Claude Code CLI with Haiku 4.5 model - Implement configurable number of commit message suggestions - Skip API key requirement for Anthropic provider (uses CLI authentication) - Add Claude Haiku 4.5 model definition to models registry - Update config system to support Anthropic-specific settings - Extend interactive config to include Anthropic provider option The Anthropic provider leverages the local Claude Code CLI installation, eliminating the need for API key management while providing fast commit message generation using the Haiku model.
1 parent 15565b9 commit c32a389

5 files changed

Lines changed: 242 additions & 35 deletions

File tree

cmd/commit.go

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,14 +40,20 @@ var commitCmd = &cobra.Command{
4040
var aiProvider CommitProvider
4141

4242
providerName := config.GetProvider()
43-
apiKey, err := config.GetAPIKey()
44-
if err != nil {
45-
fmt.Fprintf(os.Stderr, "Error getting API key: %v\n", err)
46-
os.Exit(1)
43+
44+
// API key is not needed for anthropic provider (uses CLI)
45+
var apiKey string
46+
if providerName != "anthropic" {
47+
var err error
48+
apiKey, err = config.GetAPIKey()
49+
if err != nil {
50+
fmt.Fprintf(os.Stderr, "Error getting API key: %v\n", err)
51+
os.Exit(1)
52+
}
4753
}
4854

4955
var model string
50-
if providerName == "copilot" || providerName == "openai" {
56+
if providerName == "copilot" || providerName == "openai" || providerName == "anthropic" {
5157
var err error
5258
model, err = config.GetModel()
5359
if err != nil {
@@ -67,6 +73,13 @@ var commitCmd = &cobra.Command{
6773
aiProvider = provider.NewCopilotProviderWithModel(apiKey, model, endpoint)
6874
case "openai":
6975
aiProvider = provider.NewOpenAIProvider(apiKey, model, endpoint)
76+
case "anthropic":
77+
// Get num_suggestions from config (default to 10)
78+
numSuggestions := config.GetNumSuggestions()
79+
if numSuggestions <= 0 {
80+
numSuggestions = 10
81+
}
82+
aiProvider = provider.NewAnthropicProvider(model, numSuggestions)
7083
default:
7184
// Default to copilot if provider is not set or unknown
7285
aiProvider = provider.NewCopilotProvider(apiKey, endpoint)

cmd/config.go

Lines changed: 61 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ func runInteractiveConfig() {
7979

8080
providerPrompt := &survey.Select{
8181
Message: "Choose a provider:",
82-
Options: []string{"openai", "copilot"},
82+
Options: []string{"openai", "copilot", "anthropic"},
8383
Default: currentProvider,
8484
}
8585
var selectedProvider string
@@ -99,7 +99,8 @@ func runInteractiveConfig() {
9999
currentModel = ""
100100
}
101101

102-
if selectedProvider != "copilot" {
102+
// API key configuration - skip for copilot and anthropic
103+
if selectedProvider != "copilot" && selectedProvider != "anthropic" {
103104
apiKeyPrompt := &survey.Input{
104105
Message: fmt.Sprintf("Enter API Key for %s:", selectedProvider),
105106
}
@@ -117,21 +118,31 @@ func runInteractiveConfig() {
117118
}
118119
fmt.Printf("API key for %s set.\n", selectedProvider)
119120
}
121+
} else if selectedProvider == "anthropic" {
122+
fmt.Println("Anthropic provider uses Claude Code CLI - no API key needed.")
120123
}
121124

122-
// Dynamically generate available models for OpenAI
125+
// Dynamically generate available models
123126
availableModels := map[string][]string{
124-
"openai": {},
125-
"copilot": {"openai/gpt-5-mini"}, // TODO: update if copilot models are dynamic
127+
"openai": {},
128+
"copilot": {"openai/gpt-5-mini"},
129+
"anthropic": {},
126130
}
127131

128132
modelDisplayToID := map[string]string{}
133+
129134
if selectedProvider == "openai" {
130135
for id, m := range models.OpenAIModels {
131136
display := fmt.Sprintf("%s (%s)", m.Name, string(id))
132137
availableModels["openai"] = append(availableModels["openai"], display)
133138
modelDisplayToID[display] = string(id)
134139
}
140+
} else if selectedProvider == "anthropic" {
141+
for _, m := range models.AnthropicModels {
142+
display := fmt.Sprintf("%s (%s)", m.Name, m.APIModel)
143+
availableModels["anthropic"] = append(availableModels["anthropic"], display)
144+
modelDisplayToID[display] = m.APIModel
145+
}
135146
}
136147

137148
modelPrompt := &survey.Select{
@@ -142,7 +153,7 @@ func runInteractiveConfig() {
142153
// Try to set the default to the current model if possible
143154
isValidDefault := false
144155
currentDisplay := ""
145-
if selectedProvider == "openai" {
156+
if selectedProvider == "openai" || selectedProvider == "anthropic" {
146157
for display, id := range modelDisplayToID {
147158
if id == currentModel || display == currentModel {
148159
isValidDefault = true
@@ -171,7 +182,7 @@ func runInteractiveConfig() {
171182
}
172183

173184
selectedModel := selectedDisplay
174-
if selectedProvider == "openai" {
185+
if selectedProvider == "openai" || selectedProvider == "anthropic" {
175186
selectedModel = modelDisplayToID[selectedDisplay]
176187
}
177188

@@ -184,31 +195,55 @@ func runInteractiveConfig() {
184195
fmt.Printf("Model set to: %s\n", selectedModel)
185196
}
186197

198+
// Number of suggestions configuration for anthropic
199+
if selectedProvider == "anthropic" {
200+
numSuggestionsPrompt := &survey.Input{
201+
Message: "Number of commit message suggestions (default: 10):",
202+
Default: "10",
203+
}
204+
var numSuggestions string
205+
err := survey.AskOne(numSuggestionsPrompt, &numSuggestions)
206+
if err != nil {
207+
fmt.Println(err.Error())
208+
return
209+
}
210+
if numSuggestions != "" {
211+
err := config.SetNumSuggestions(selectedProvider, numSuggestions)
212+
if err != nil {
213+
fmt.Printf("Error setting num_suggestions: %v\n", err)
214+
return
215+
}
216+
fmt.Printf("Number of suggestions set to: %s\n", numSuggestions)
217+
}
218+
}
219+
187220
// Get current endpoint
188221
currentEndpoint, _ := config.GetEndpoint()
189222

190-
// Endpoint configuration prompt
191-
endpointPrompt := &survey.Input{
192-
Message: "Enter custom endpoint URL (leave empty for default):",
193-
Default: currentEndpoint,
194-
}
195-
var endpoint string
196-
err = survey.AskOne(endpointPrompt, &endpoint, survey.WithValidator(validateEndpointURL))
197-
if err != nil {
198-
fmt.Println(err.Error())
199-
return
200-
}
201-
202-
// Only set endpoint if it's different from current
203-
if endpoint != currentEndpoint && endpoint != "" {
204-
err := config.SetEndpoint(selectedProvider, endpoint)
223+
// Endpoint configuration prompt - skip for anthropic since it uses CLI
224+
if selectedProvider != "anthropic" {
225+
endpointPrompt := &survey.Input{
226+
Message: "Enter custom endpoint URL (leave empty for default):",
227+
Default: currentEndpoint,
228+
}
229+
var endpoint string
230+
err = survey.AskOne(endpointPrompt, &endpoint, survey.WithValidator(validateEndpointURL))
205231
if err != nil {
206-
fmt.Printf("Error setting endpoint: %v\n", err)
232+
fmt.Println(err.Error())
207233
return
208234
}
209-
fmt.Printf("Endpoint set to: %s\n", endpoint)
210-
} else if endpoint == "" {
211-
fmt.Println("Using default endpoint for provider")
235+
236+
// Only set endpoint if it's different from current
237+
if endpoint != currentEndpoint && endpoint != "" {
238+
err := config.SetEndpoint(selectedProvider, endpoint)
239+
if err != nil {
240+
fmt.Printf("Error setting endpoint: %v\n", err)
241+
return
242+
}
243+
fmt.Printf("Endpoint set to: %s\n", endpoint)
244+
} else if endpoint == "" {
245+
fmt.Println("Using default endpoint for provider")
246+
}
212247
}
213248
}
214249

internal/config/config.go

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,10 @@ import (
1414
)
1515

1616
type ProviderConfig struct {
17-
APIKey string `mapstructure:"api_key"`
18-
Model string `mapstructure:"model"`
19-
EndpointURL string `mapstructure:"endpoint_url"`
17+
APIKey string `mapstructure:"api_key"`
18+
Model string `mapstructure:"model"`
19+
EndpointURL string `mapstructure:"endpoint_url"`
20+
NumSuggestions int `mapstructure:"num_suggestions"`
2021
}
2122

2223
type Config struct {
@@ -41,6 +42,10 @@ func InitConfig() {
4142
viper.SetDefault("providers.openai.model", "openai/gpt-5-mini")
4243
}
4344

45+
// Set defaults for anthropic provider
46+
viper.SetDefault("providers.anthropic.model", "claude-haiku-4-5")
47+
viper.SetDefault("providers.anthropic.num_suggestions", 10)
48+
4449
viper.AutomaticEnv()
4550

4651
if err := viper.ReadInConfig(); err != nil {
@@ -144,6 +149,8 @@ func GetEndpoint() (string, error) {
144149
return "https://api.openai.com/v1", nil
145150
case "copilot":
146151
return "https://api.githubcopilot.com", nil
152+
case "anthropic":
153+
return "", nil // Anthropic uses CLI, no endpoint needed
147154
default:
148155
return "", fmt.Errorf("no default endpoint available for provider '%s'", cfg.ActiveProvider)
149156
}
@@ -257,6 +264,28 @@ func tryGetTokenFromGHCLI() (string, error) {
257264
return tok, nil
258265
}
259266

267+
func GetNumSuggestions() int {
268+
if cfg == nil {
269+
InitConfig()
270+
}
271+
providerConfig, err := GetActiveProviderConfig()
272+
if err != nil {
273+
return 10 // Default to 10 if error
274+
}
275+
if providerConfig.NumSuggestions <= 0 {
276+
return 10 // Default to 10 if not set or invalid
277+
}
278+
return providerConfig.NumSuggestions
279+
}
280+
281+
func SetNumSuggestions(provider, numSuggestions string) error {
282+
if cfg == nil {
283+
InitConfig()
284+
}
285+
viper.Set(fmt.Sprintf("providers.%s.num_suggestions", provider), numSuggestions)
286+
return viper.WriteConfig()
287+
}
288+
260289
func getConfigDir() string {
261290
if xdgConfig := os.Getenv("XDG_CONFIG_HOME"); xdgConfig != "" {
262291
return xdgConfig

internal/provider/anthropic.go

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
package provider
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"os/exec"
7+
"strings"
8+
)
9+
10+
type AnthropicProvider struct {
11+
model string
12+
numSuggestions int
13+
}
14+
15+
func NewAnthropicProvider(model string, numSuggestions int) *AnthropicProvider {
16+
if model == "" {
17+
model = "claude-haiku-4-5"
18+
}
19+
if numSuggestions <= 0 {
20+
numSuggestions = 10
21+
}
22+
return &AnthropicProvider{
23+
model: model,
24+
numSuggestions: numSuggestions,
25+
}
26+
}
27+
28+
func (a *AnthropicProvider) GenerateCommitMessage(ctx context.Context, diff string) (string, error) {
29+
msgs, err := a.GenerateCommitMessages(ctx, diff)
30+
if err != nil {
31+
return "", err
32+
}
33+
if len(msgs) == 0 {
34+
return "", fmt.Errorf("no commit messages generated")
35+
}
36+
return msgs[0], nil
37+
}
38+
39+
func (a *AnthropicProvider) GenerateCommitMessages(ctx context.Context, diff string) ([]string, error) {
40+
if strings.TrimSpace(diff) == "" {
41+
return nil, fmt.Errorf("no diff provided")
42+
}
43+
44+
// Check if claude CLI is available
45+
if _, err := exec.LookPath("claude"); err != nil {
46+
return nil, fmt.Errorf("claude CLI not found in PATH. Please install Claude Code CLI: %w", err)
47+
}
48+
49+
// Build the prompt
50+
systemMsg := GetSystemMessage()
51+
userPrompt := GetCommitMessagePrompt(diff)
52+
53+
// Modify the prompt to request specific number of suggestions
54+
fullPrompt := fmt.Sprintf("%s\n\nUser request: %s\n\nIMPORTANT: Generate exactly %d commit messages, one per line. Do not include any other text, explanations, or formatting - just the commit messages.",
55+
systemMsg, userPrompt, a.numSuggestions)
56+
57+
// Execute claude CLI with haiku model
58+
// Using -p flag for print mode and --model for model selection
59+
cmd := exec.CommandContext(ctx, "claude", "--model", a.model, "-p", fullPrompt)
60+
61+
output, err := cmd.CombinedOutput()
62+
if err != nil {
63+
return nil, fmt.Errorf("error executing claude CLI: %w\nOutput: %s", err, string(output))
64+
}
65+
66+
// Parse the output - split by newlines and clean
67+
content := string(output)
68+
lines := strings.Split(content, "\n")
69+
70+
var commitMessages []string
71+
for _, line := range lines {
72+
trimmed := strings.TrimSpace(line)
73+
// Skip empty lines and lines that look like explanatory text
74+
if trimmed == "" {
75+
continue
76+
}
77+
// Skip lines that are clearly not commit messages (too long, contain certain patterns)
78+
if len(trimmed) > 200 {
79+
continue
80+
}
81+
// Skip markdown formatting or numbered lists
82+
if strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, "-") || strings.HasPrefix(trimmed, "*") {
83+
// Try to extract the actual commit message
84+
parts := strings.SplitN(trimmed, " ", 2)
85+
if len(parts) == 2 {
86+
trimmed = strings.TrimSpace(parts[1])
87+
}
88+
}
89+
// Remove numbered list formatting like "1. " or "1) "
90+
if len(trimmed) > 3 {
91+
if (trimmed[0] >= '0' && trimmed[0] <= '9') && (trimmed[1] == '.' || trimmed[1] == ')') {
92+
trimmed = strings.TrimSpace(trimmed[2:])
93+
}
94+
}
95+
96+
if trimmed != "" {
97+
commitMessages = append(commitMessages, trimmed)
98+
}
99+
100+
// Stop once we have enough messages
101+
if len(commitMessages) >= a.numSuggestions {
102+
break
103+
}
104+
}
105+
106+
if len(commitMessages) == 0 {
107+
return nil, fmt.Errorf("no valid commit messages generated from Claude output")
108+
}
109+
110+
return commitMessages, nil
111+
}

0 commit comments

Comments
 (0)