Skip to content

Commit b10cebf

Browse files
authored
Merge pull request #36 from stn1slv/main
Add Gemini CLI provider support and fix output parsing issues
2 parents ee2fa12 + ed50c90 commit b10cebf

11 files changed

Lines changed: 424 additions & 140 deletions

File tree

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,9 @@ providers:
9393
anthropic:
9494
model: "claude-haiku-4-5" # Uses Claude Code CLI - no API key needed
9595
num_suggestions: 10 # Number of commit suggestions to generate
96+
gemini:
97+
model: "flash" # Uses Gemini CLI - no API key needed
98+
num_suggestions: 10 # Number of commit suggestions to generate
9699
```
97100
98101
> [!NOTE]

cmd/commit.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,12 @@ var commitCmd = &cobra.Command{
8080
numSuggestions = 10
8181
}
8282
aiProvider = provider.NewAnthropicProvider(model, numSuggestions)
83+
case "gemini":
84+
numSuggestions := config.GetNumSuggestions()
85+
if numSuggestions <= 0 {
86+
numSuggestions = 10
87+
}
88+
aiProvider = provider.NewGeminiProvider(model, numSuggestions)
8389
default:
8490
// Default to copilot if provider is not set or unknown
8591
aiProvider = provider.NewCopilotProvider(apiKey, endpoint)

cmd/config.go

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ func runInteractiveConfig() {
8181

8282
providerPrompt := &survey.Select{
8383
Message: "Choose a provider:",
84-
Options: []string{"openai", "copilot", "anthropic"},
84+
Options: []string{"openai", "copilot", "anthropic", "gemini"},
8585
Default: currentProvider,
8686
}
8787
var selectedProvider string
@@ -152,8 +152,8 @@ func runInteractiveConfig() {
152152
fmt.Printf("Language set to: %s\n", langValue)
153153
}
154154

155-
// API key configuration - skip for copilot and anthropic
156-
if selectedProvider != "copilot" && selectedProvider != "anthropic" {
155+
// API key configuration - skip for copilot, anthropic and gemini
156+
if selectedProvider != "copilot" && selectedProvider != "anthropic" && selectedProvider != "gemini" {
157157
apiKeyPrompt := &survey.Input{
158158
Message: fmt.Sprintf("Enter API Key for %s:", selectedProvider),
159159
}
@@ -173,12 +173,15 @@ func runInteractiveConfig() {
173173
}
174174
} else if selectedProvider == "anthropic" {
175175
fmt.Println("Anthropic provider uses Claude Code CLI - no API key needed.")
176+
} else if selectedProvider == "gemini" {
177+
fmt.Println("Gemini provider uses Gemini CLI - no API key needed.")
176178
}
177179

178180
availableModels := map[string][]string{
179181
"openai": {},
180182
"copilot": {},
181183
"anthropic": {},
184+
"gemini": {},
182185
}
183186

184187
modelDisplayToID := map[string]string{}
@@ -213,6 +216,12 @@ func runInteractiveConfig() {
213216
availableModels["anthropic"] = append(availableModels["anthropic"], display)
214217
modelDisplayToID[display] = m.APIModel
215218
}
219+
case "gemini":
220+
for _, m := range models.GeminiModels {
221+
display := fmt.Sprintf("%s (%s)", m.Name, m.APIModel)
222+
availableModels["gemini"] = append(availableModels["gemini"], display)
223+
modelDisplayToID[display] = m.APIModel
224+
}
216225
}
217226

218227
modelPrompt := &survey.Select{
@@ -223,7 +232,7 @@ func runInteractiveConfig() {
223232
// Try to set the default to the current model if possible
224233
isValidDefault := false
225234
currentDisplay := ""
226-
if selectedProvider == "openai" || selectedProvider == "anthropic" || selectedProvider == "copilot" {
235+
if selectedProvider == "openai" || selectedProvider == "anthropic" || selectedProvider == "copilot" || selectedProvider == "gemini" {
227236
for display, id := range modelDisplayToID {
228237
if id == currentModel || display == currentModel {
229238
isValidDefault = true
@@ -252,7 +261,7 @@ func runInteractiveConfig() {
252261
}
253262

254263
selectedModel := selectedDisplay
255-
if selectedProvider == "openai" || selectedProvider == "anthropic" || selectedProvider == "copilot" {
264+
if selectedProvider == "openai" || selectedProvider == "anthropic" || selectedProvider == "copilot" || selectedProvider == "gemini" {
256265
selectedModel = modelDisplayToID[selectedDisplay]
257266
}
258267

@@ -265,8 +274,8 @@ func runInteractiveConfig() {
265274
fmt.Printf("Model set to: %s\n", selectedModel)
266275
}
267276

268-
// Number of suggestions configuration for anthropic
269-
if selectedProvider == "anthropic" {
277+
// Number of suggestions configuration for anthropic and gemini
278+
if selectedProvider == "anthropic" || selectedProvider == "gemini" {
270279
numSuggestionsPrompt := &survey.Input{
271280
Message: "Number of commit message suggestions (default: 10):",
272281
Default: "10",
@@ -290,8 +299,8 @@ func runInteractiveConfig() {
290299
// Get current endpoint
291300
currentEndpoint, _ := config.GetEndpoint()
292301

293-
// Endpoint configuration prompt - skip for anthropic since it uses CLI
294-
if selectedProvider != "anthropic" {
302+
// Endpoint configuration prompt - skip for anthropic and gemini since they use CLI
303+
if selectedProvider != "anthropic" && selectedProvider != "gemini" {
295304
endpointPrompt := &survey.Input{
296305
Message: "Enter custom endpoint URL (leave empty for default):",
297306
Default: currentEndpoint,

cmd/pr.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,9 @@ var prCmd = &cobra.Command{
8787
// Get num_suggestions from config
8888
numSuggestions := config.GetNumSuggestions()
8989
aiProvider = provider.NewAnthropicProvider(model, numSuggestions)
90+
case "gemini":
91+
numSuggestions := config.GetNumSuggestions()
92+
aiProvider = provider.NewGeminiProvider(model, numSuggestions)
9093
default:
9194
// Default to copilot if provider is not set or unknown
9295
aiProvider = provider.NewCopilotProvider(apiKey, endpoint)

internal/config/config.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ func InitConfig() {
4545

4646
viper.SetDefault("providers.anthropic.model", "claude-haiku-4-5")
4747
viper.SetDefault("providers.anthropic.num_suggestions", 10)
48+
viper.SetDefault("providers.gemini.model", "flash")
49+
viper.SetDefault("providers.gemini.num_suggestions", 10)
4850
viper.AutomaticEnv()
4951

5052
if err := viper.ReadInConfig(); err != nil {
@@ -149,6 +151,8 @@ func GetEndpoint() (string, error) {
149151
return "https://api.githubcopilot.com", nil
150152
case "anthropic":
151153
return "", nil // Anthropic uses CLI, no endpoint needed
154+
case "gemini":
155+
return "", nil // Gemini uses CLI, no endpoint needed
152156
default:
153157
return "", fmt.Errorf("no default endpoint available for provider '%s'", cfg.ActiveProvider)
154158
}

internal/config/config_test.go

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -162,8 +162,8 @@ func TestGetEndpoint_CustomEndpoint(t *testing.T) {
162162
cfg.Providers = make(map[string]ProviderConfig)
163163
}
164164
cfg.Providers[testProvider] = ProviderConfig{
165-
APIKey: "test-key",
166-
Model: "test-model",
165+
APIKey: "test-key",
166+
Model: "test-model",
167167
EndpointURL: customEndpoint,
168168
}
169169

@@ -221,12 +221,12 @@ func TestSetEndpoint_Validation(t *testing.T) {
221221
endpoint string
222222
valid bool
223223
}{
224-
{"", true}, // Empty should be valid (default)
225-
{"https://api.openai.com/v1", true}, // Valid HTTPS URL
226-
{"http://localhost:11434", true}, // Valid HTTP URL
227-
{"ftp://invalid.com", false}, // Invalid protocol
228-
{"not-a-url", false}, // Invalid format
229-
{"https://", false}, // Missing host
224+
{"", true}, // Empty should be valid (default)
225+
{"https://api.openai.com/v1", true}, // Valid HTTPS URL
226+
{"http://localhost:11434", true}, // Valid HTTP URL
227+
{"ftp://invalid.com", false}, // Invalid protocol
228+
{"not-a-url", false}, // Invalid format
229+
{"https://", false}, // Missing host
230230
}
231231

232232
for _, tc := range testCases {
@@ -237,4 +237,4 @@ func TestSetEndpoint_Validation(t *testing.T) {
237237
t.Errorf("Expected invalid endpoint %s to fail, but it passed", tc.endpoint)
238238
}
239239
}
240-
}
240+
}

internal/provider/anthropic.go

Lines changed: 28 additions & 122 deletions
Original file line numberDiff line numberDiff line change
@@ -54,79 +54,12 @@ func (a *AnthropicProvider) GenerateCommitMessages(ctx context.Context, diff str
5454
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.",
5555
systemMsg, userPrompt, a.numSuggestions)
5656

57-
// Execute claude CLI with haiku model
58-
// Using -p flag for print mode and --model for model selection
59-
// Pipe prompt via stdin to avoid Windows command line length limits (8191 chars)
60-
cmd := exec.CommandContext(ctx, "claude", "--model", a.model, "-p", "-")
61-
62-
stdin, err := cmd.StdinPipe()
57+
output, err := a.runCLI(ctx, fullPrompt)
6358
if err != nil {
64-
return nil, fmt.Errorf("error creating stdin pipe: %w", err)
65-
}
66-
67-
var outputBuf strings.Builder
68-
cmd.Stdout = &outputBuf
69-
cmd.Stderr = &outputBuf
70-
71-
if err := cmd.Start(); err != nil {
72-
return nil, fmt.Errorf("error starting claude CLI: %w", err)
73-
}
74-
75-
_, writeErr := stdin.Write([]byte(fullPrompt))
76-
stdin.Close()
77-
78-
waitErr := cmd.Wait()
79-
80-
if writeErr != nil {
81-
return nil, fmt.Errorf("error writing to claude CLI stdin: %w", writeErr)
82-
}
83-
84-
if waitErr != nil {
85-
return nil, fmt.Errorf("error executing claude CLI: %w\nOutput: %s", waitErr, outputBuf.String())
86-
}
87-
88-
output := []byte(outputBuf.String())
89-
90-
// Parse the output - split by newlines and clean
91-
content := string(output)
92-
lines := strings.Split(content, "\n")
93-
94-
var commitMessages []string
95-
for _, line := range lines {
96-
trimmed := strings.TrimSpace(line)
97-
// Skip empty lines and lines that look like explanatory text
98-
if trimmed == "" {
99-
continue
100-
}
101-
// Skip lines that are clearly not commit messages (too long, contain certain patterns)
102-
if len(trimmed) > 200 {
103-
continue
104-
}
105-
// Skip markdown formatting or numbered lists
106-
if strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, "-") || strings.HasPrefix(trimmed, "*") {
107-
// Try to extract the actual commit message
108-
parts := strings.SplitN(trimmed, " ", 2)
109-
if len(parts) == 2 {
110-
trimmed = strings.TrimSpace(parts[1])
111-
}
112-
}
113-
// Remove numbered list formatting like "1. " or "1) "
114-
if len(trimmed) > 3 {
115-
if (trimmed[0] >= '0' && trimmed[0] <= '9') && (trimmed[1] == '.' || trimmed[1] == ')') {
116-
trimmed = strings.TrimSpace(trimmed[2:])
117-
}
118-
}
119-
120-
if trimmed != "" {
121-
commitMessages = append(commitMessages, trimmed)
122-
}
123-
124-
// Stop once we have enough messages
125-
if len(commitMessages) >= a.numSuggestions {
126-
break
127-
}
59+
return nil, err
12860
}
12961

62+
commitMessages := parseOutputLines(output, a.numSuggestions)
13063
if len(commitMessages) == 0 {
13164
return nil, fmt.Errorf("no valid commit messages generated from Claude output")
13265
}
@@ -163,77 +96,50 @@ func (a *AnthropicProvider) GeneratePRTitles(ctx context.Context, diff string) (
16396
fullPrompt := fmt.Sprintf("%s\n\nUser request: %s\n\nIMPORTANT: Generate exactly %d pull request titles, one per line. Do not include any other text, explanations, or formatting - just the PR titles.",
16497
systemMsg, userPrompt, a.numSuggestions)
16598

99+
output, err := a.runCLI(ctx, fullPrompt)
100+
if err != nil {
101+
return nil, err
102+
}
103+
104+
prTitles := parseOutputLines(output, a.numSuggestions)
105+
if len(prTitles) == 0 {
106+
return nil, fmt.Errorf("no valid PR titles generated from Claude output")
107+
}
108+
109+
return prTitles, nil
110+
}
111+
112+
// runCLI executes the claude CLI with the given prompt via stdin and returns stdout.
113+
func (a *AnthropicProvider) runCLI(ctx context.Context, prompt string) (string, error) {
114+
// Using -p flag for print mode and --model for model selection
166115
// Pipe prompt via stdin to avoid Windows command line length limits (8191 chars)
167116
cmd := exec.CommandContext(ctx, "claude", "--model", a.model, "-p", "-")
168117

169118
stdin, err := cmd.StdinPipe()
170119
if err != nil {
171-
return nil, fmt.Errorf("error creating stdin pipe: %w", err)
120+
return "", fmt.Errorf("error creating stdin pipe: %w", err)
172121
}
173122

174-
var outputBuf strings.Builder
175-
cmd.Stdout = &outputBuf
176-
cmd.Stderr = &outputBuf
123+
var stdoutBuf, stderrBuf strings.Builder
124+
cmd.Stdout = &stdoutBuf
125+
cmd.Stderr = &stderrBuf
177126

178127
if err := cmd.Start(); err != nil {
179-
return nil, fmt.Errorf("error starting claude CLI: %w", err)
128+
return "", fmt.Errorf("error starting claude CLI: %w", err)
180129
}
181130

182-
_, writeErr := stdin.Write([]byte(fullPrompt))
131+
_, writeErr := stdin.Write([]byte(prompt))
183132
stdin.Close()
184133

185134
waitErr := cmd.Wait()
186135

187136
if writeErr != nil {
188-
return nil, fmt.Errorf("error writing to claude CLI stdin: %w", writeErr)
137+
return "", fmt.Errorf("error writing to claude CLI stdin: %w", writeErr)
189138
}
190139

191140
if waitErr != nil {
192-
return nil, fmt.Errorf("error executing claude CLI: %w\nOutput: %s", waitErr, outputBuf.String())
193-
}
194-
195-
output := []byte(outputBuf.String())
196-
197-
// Parse the output - same logic as commit message generation
198-
content := string(output)
199-
lines := strings.Split(content, "\n")
200-
201-
var prTitles []string
202-
for _, line := range lines {
203-
trimmed := strings.TrimSpace(line)
204-
if trimmed == "" {
205-
continue
206-
}
207-
if len(trimmed) > 200 {
208-
continue
209-
}
210-
// Skip markdown formatting or numbered lists
211-
if strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, "-") || strings.HasPrefix(trimmed, "*") {
212-
parts := strings.SplitN(trimmed, " ", 2)
213-
if len(parts) == 2 {
214-
trimmed = strings.TrimSpace(parts[1])
215-
}
216-
}
217-
// Remove numbered list formatting like "1. " or "1) "
218-
if len(trimmed) > 3 {
219-
if (trimmed[0] >= '0' && trimmed[0] <= '9') && (trimmed[1] == '.' || trimmed[1] == ')') {
220-
trimmed = strings.TrimSpace(trimmed[2:])
221-
}
222-
}
223-
224-
if trimmed != "" {
225-
prTitles = append(prTitles, trimmed)
226-
}
227-
228-
// Stop once we have enough titles
229-
if len(prTitles) >= a.numSuggestions {
230-
break
231-
}
232-
}
233-
234-
if len(prTitles) == 0 {
235-
return nil, fmt.Errorf("no valid PR titles generated from Claude output")
141+
return "", fmt.Errorf("error executing claude CLI: %w\nStderr: %s", waitErr, stderrBuf.String())
236142
}
237143

238-
return prTitles, nil
144+
return stdoutBuf.String(), nil
239145
}

0 commit comments

Comments
 (0)