Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,9 @@ providers:
anthropic:
model: "claude-haiku-4-5" # Uses Claude Code CLI - no API key needed
num_suggestions: 10 # Number of commit suggestions to generate
gemini:
model: "flash" # Uses Gemini CLI - no API key needed
num_suggestions: 10 # Number of commit suggestions to generate
```

> [!NOTE]
Expand Down
6 changes: 6 additions & 0 deletions cmd/commit.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@ var commitCmd = &cobra.Command{
numSuggestions = 10
}
aiProvider = provider.NewAnthropicProvider(model, numSuggestions)
case "gemini":
numSuggestions := config.GetNumSuggestions()
if numSuggestions <= 0 {
numSuggestions = 10
}
aiProvider = provider.NewGeminiProvider(model, numSuggestions)
default:
// Default to copilot if provider is not set or unknown
aiProvider = provider.NewCopilotProvider(apiKey, endpoint)
Expand Down
27 changes: 18 additions & 9 deletions cmd/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ func runInteractiveConfig() {

providerPrompt := &survey.Select{
Message: "Choose a provider:",
Options: []string{"openai", "copilot", "anthropic"},
Options: []string{"openai", "copilot", "anthropic", "gemini"},
Default: currentProvider,
}
var selectedProvider string
Expand Down Expand Up @@ -152,8 +152,8 @@ func runInteractiveConfig() {
fmt.Printf("Language set to: %s\n", langValue)
}

// API key configuration - skip for copilot and anthropic
if selectedProvider != "copilot" && selectedProvider != "anthropic" {
// API key configuration - skip for copilot, anthropic and gemini
if selectedProvider != "copilot" && selectedProvider != "anthropic" && selectedProvider != "gemini" {
apiKeyPrompt := &survey.Input{
Message: fmt.Sprintf("Enter API Key for %s:", selectedProvider),
}
Expand All @@ -173,12 +173,15 @@ func runInteractiveConfig() {
}
} else if selectedProvider == "anthropic" {
fmt.Println("Anthropic provider uses Claude Code CLI - no API key needed.")
} else if selectedProvider == "gemini" {
fmt.Println("Gemini provider uses Gemini CLI - no API key needed.")
}

availableModels := map[string][]string{
"openai": {},
"copilot": {},
"anthropic": {},
"gemini": {},
}

modelDisplayToID := map[string]string{}
Expand Down Expand Up @@ -213,6 +216,12 @@ func runInteractiveConfig() {
availableModels["anthropic"] = append(availableModels["anthropic"], display)
modelDisplayToID[display] = m.APIModel
}
case "gemini":
for _, m := range models.GeminiModels {
display := fmt.Sprintf("%s (%s)", m.Name, m.APIModel)
availableModels["gemini"] = append(availableModels["gemini"], display)
modelDisplayToID[display] = m.APIModel
}
}

modelPrompt := &survey.Select{
Expand All @@ -223,7 +232,7 @@ func runInteractiveConfig() {
// Try to set the default to the current model if possible
isValidDefault := false
currentDisplay := ""
if selectedProvider == "openai" || selectedProvider == "anthropic" || selectedProvider == "copilot" {
if selectedProvider == "openai" || selectedProvider == "anthropic" || selectedProvider == "copilot" || selectedProvider == "gemini" {
for display, id := range modelDisplayToID {
if id == currentModel || display == currentModel {
isValidDefault = true
Expand Down Expand Up @@ -252,7 +261,7 @@ func runInteractiveConfig() {
}

selectedModel := selectedDisplay
if selectedProvider == "openai" || selectedProvider == "anthropic" || selectedProvider == "copilot" {
if selectedProvider == "openai" || selectedProvider == "anthropic" || selectedProvider == "copilot" || selectedProvider == "gemini" {
selectedModel = modelDisplayToID[selectedDisplay]
}

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

// Number of suggestions configuration for anthropic
if selectedProvider == "anthropic" {
// Number of suggestions configuration for anthropic and gemini
if selectedProvider == "anthropic" || selectedProvider == "gemini" {
numSuggestionsPrompt := &survey.Input{
Message: "Number of commit message suggestions (default: 10):",
Default: "10",
Expand All @@ -290,8 +299,8 @@ func runInteractiveConfig() {
// Get current endpoint
currentEndpoint, _ := config.GetEndpoint()

// Endpoint configuration prompt - skip for anthropic since it uses CLI
if selectedProvider != "anthropic" {
// Endpoint configuration prompt - skip for anthropic and gemini since they use CLI
if selectedProvider != "anthropic" && selectedProvider != "gemini" {
endpointPrompt := &survey.Input{
Message: "Enter custom endpoint URL (leave empty for default):",
Default: currentEndpoint,
Expand Down
3 changes: 3 additions & 0 deletions cmd/pr.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ var prCmd = &cobra.Command{
// Get num_suggestions from config
numSuggestions := config.GetNumSuggestions()
aiProvider = provider.NewAnthropicProvider(model, numSuggestions)
case "gemini":
numSuggestions := config.GetNumSuggestions()
aiProvider = provider.NewGeminiProvider(model, numSuggestions)
default:
// Default to copilot if provider is not set or unknown
aiProvider = provider.NewCopilotProvider(apiKey, endpoint)
Expand Down
4 changes: 4 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ func InitConfig() {

viper.SetDefault("providers.anthropic.model", "claude-haiku-4-5")
viper.SetDefault("providers.anthropic.num_suggestions", 10)
viper.SetDefault("providers.gemini.model", "flash")
viper.SetDefault("providers.gemini.num_suggestions", 10)
viper.AutomaticEnv()

if err := viper.ReadInConfig(); err != nil {
Expand Down Expand Up @@ -149,6 +151,8 @@ func GetEndpoint() (string, error) {
return "https://api.githubcopilot.com", nil
case "anthropic":
return "", nil // Anthropic uses CLI, no endpoint needed
case "gemini":
return "", nil // Gemini uses CLI, no endpoint needed
default:
return "", fmt.Errorf("no default endpoint available for provider '%s'", cfg.ActiveProvider)
}
Expand Down
18 changes: 9 additions & 9 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,8 +162,8 @@ func TestGetEndpoint_CustomEndpoint(t *testing.T) {
cfg.Providers = make(map[string]ProviderConfig)
}
cfg.Providers[testProvider] = ProviderConfig{
APIKey: "test-key",
Model: "test-model",
APIKey: "test-key",
Model: "test-model",
EndpointURL: customEndpoint,
}

Expand Down Expand Up @@ -221,12 +221,12 @@ func TestSetEndpoint_Validation(t *testing.T) {
endpoint string
valid bool
}{
{"", true}, // Empty should be valid (default)
{"https://api.openai.com/v1", true}, // Valid HTTPS URL
{"http://localhost:11434", true}, // Valid HTTP URL
{"ftp://invalid.com", false}, // Invalid protocol
{"not-a-url", false}, // Invalid format
{"https://", false}, // Missing host
{"", true}, // Empty should be valid (default)
{"https://api.openai.com/v1", true}, // Valid HTTPS URL
{"http://localhost:11434", true}, // Valid HTTP URL
{"ftp://invalid.com", false}, // Invalid protocol
{"not-a-url", false}, // Invalid format
{"https://", false}, // Missing host
}

for _, tc := range testCases {
Expand All @@ -237,4 +237,4 @@ func TestSetEndpoint_Validation(t *testing.T) {
t.Errorf("Expected invalid endpoint %s to fail, but it passed", tc.endpoint)
}
}
}
}
150 changes: 28 additions & 122 deletions internal/provider/anthropic.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,79 +54,12 @@ func (a *AnthropicProvider) GenerateCommitMessages(ctx context.Context, diff str
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.",
systemMsg, userPrompt, a.numSuggestions)

// Execute claude CLI with haiku model
// Using -p flag for print mode and --model for model selection
// Pipe prompt via stdin to avoid Windows command line length limits (8191 chars)
cmd := exec.CommandContext(ctx, "claude", "--model", a.model, "-p", "-")

stdin, err := cmd.StdinPipe()
output, err := a.runCLI(ctx, fullPrompt)
if err != nil {
return nil, fmt.Errorf("error creating stdin pipe: %w", err)
}

var outputBuf strings.Builder
cmd.Stdout = &outputBuf
cmd.Stderr = &outputBuf

if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("error starting claude CLI: %w", err)
}

_, writeErr := stdin.Write([]byte(fullPrompt))
stdin.Close()

waitErr := cmd.Wait()

if writeErr != nil {
return nil, fmt.Errorf("error writing to claude CLI stdin: %w", writeErr)
}

if waitErr != nil {
return nil, fmt.Errorf("error executing claude CLI: %w\nOutput: %s", waitErr, outputBuf.String())
}

output := []byte(outputBuf.String())

// Parse the output - split by newlines and clean
content := string(output)
lines := strings.Split(content, "\n")

var commitMessages []string
for _, line := range lines {
trimmed := strings.TrimSpace(line)
// Skip empty lines and lines that look like explanatory text
if trimmed == "" {
continue
}
// Skip lines that are clearly not commit messages (too long, contain certain patterns)
if len(trimmed) > 200 {
continue
}
// Skip markdown formatting or numbered lists
if strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, "-") || strings.HasPrefix(trimmed, "*") {
// Try to extract the actual commit message
parts := strings.SplitN(trimmed, " ", 2)
if len(parts) == 2 {
trimmed = strings.TrimSpace(parts[1])
}
}
// Remove numbered list formatting like "1. " or "1) "
if len(trimmed) > 3 {
if (trimmed[0] >= '0' && trimmed[0] <= '9') && (trimmed[1] == '.' || trimmed[1] == ')') {
trimmed = strings.TrimSpace(trimmed[2:])
}
}

if trimmed != "" {
commitMessages = append(commitMessages, trimmed)
}

// Stop once we have enough messages
if len(commitMessages) >= a.numSuggestions {
break
}
return nil, err
}

commitMessages := parseOutputLines(output, a.numSuggestions)
if len(commitMessages) == 0 {
return nil, fmt.Errorf("no valid commit messages generated from Claude output")
}
Expand Down Expand Up @@ -163,77 +96,50 @@ func (a *AnthropicProvider) GeneratePRTitles(ctx context.Context, diff string) (
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.",
systemMsg, userPrompt, a.numSuggestions)

output, err := a.runCLI(ctx, fullPrompt)
if err != nil {
return nil, err
}

prTitles := parseOutputLines(output, a.numSuggestions)
if len(prTitles) == 0 {
return nil, fmt.Errorf("no valid PR titles generated from Claude output")
}

return prTitles, nil
}

// runCLI executes the claude CLI with the given prompt via stdin and returns stdout.
func (a *AnthropicProvider) runCLI(ctx context.Context, prompt string) (string, error) {
// Using -p flag for print mode and --model for model selection
// Pipe prompt via stdin to avoid Windows command line length limits (8191 chars)
cmd := exec.CommandContext(ctx, "claude", "--model", a.model, "-p", "-")

stdin, err := cmd.StdinPipe()
if err != nil {
return nil, fmt.Errorf("error creating stdin pipe: %w", err)
return "", fmt.Errorf("error creating stdin pipe: %w", err)
}

var outputBuf strings.Builder
cmd.Stdout = &outputBuf
cmd.Stderr = &outputBuf
var stdoutBuf, stderrBuf strings.Builder
cmd.Stdout = &stdoutBuf
cmd.Stderr = &stderrBuf

if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("error starting claude CLI: %w", err)
return "", fmt.Errorf("error starting claude CLI: %w", err)
}

_, writeErr := stdin.Write([]byte(fullPrompt))
_, writeErr := stdin.Write([]byte(prompt))
stdin.Close()

waitErr := cmd.Wait()

if writeErr != nil {
return nil, fmt.Errorf("error writing to claude CLI stdin: %w", writeErr)
return "", fmt.Errorf("error writing to claude CLI stdin: %w", writeErr)
}

if waitErr != nil {
return nil, fmt.Errorf("error executing claude CLI: %w\nOutput: %s", waitErr, outputBuf.String())
}

output := []byte(outputBuf.String())

// Parse the output - same logic as commit message generation
content := string(output)
lines := strings.Split(content, "\n")

var prTitles []string
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
if len(trimmed) > 200 {
continue
}
// Skip markdown formatting or numbered lists
if strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, "-") || strings.HasPrefix(trimmed, "*") {
parts := strings.SplitN(trimmed, " ", 2)
if len(parts) == 2 {
trimmed = strings.TrimSpace(parts[1])
}
}
// Remove numbered list formatting like "1. " or "1) "
if len(trimmed) > 3 {
if (trimmed[0] >= '0' && trimmed[0] <= '9') && (trimmed[1] == '.' || trimmed[1] == ')') {
trimmed = strings.TrimSpace(trimmed[2:])
}
}

if trimmed != "" {
prTitles = append(prTitles, trimmed)
}

// Stop once we have enough titles
if len(prTitles) >= a.numSuggestions {
break
}
}

if len(prTitles) == 0 {
return nil, fmt.Errorf("no valid PR titles generated from Claude output")
return "", fmt.Errorf("error executing claude CLI: %w\nStderr: %s", waitErr, stderrBuf.String())
}

return prTitles, nil
return stdoutBuf.String(), nil
}
Loading