├── test.txt ├── main.go ├── cmd ├── root.go ├── commit.go ├── pr.go └── config.go ├── internal ├── provider │ ├── prompts.go │ ├── openai.go │ ├── common.go │ ├── anthropic.go │ ├── models │ │ └── models.go │ └── copilot.go ├── git │ └── git.go └── config │ ├── prompts.go │ ├── config_test.go │ └── config.go ├── .goreleaser.yaml ├── .github └── workflows │ └── release.yml ├── .gitignore ├── Makefile ├── go.mod ├── README.md └── go.sum /test.txt: -------------------------------------------------------------------------------- 1 | test change 2 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/m7medvision/lazycommit/cmd" 5 | ) 6 | 7 | func main() { 8 | cmd.Execute() 9 | } 10 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/m7medvision/lazycommit/internal/config" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | var RootCmd = &cobra.Command{ 12 | Use: "lazycommit", 13 | Short: "lazycommit generates AI-powered git commit messages", 14 | Long: `lazycommit uses AI to analyze your staged changes and 15 | generates a conventional commit message for you.`, 16 | } 17 | 18 | func Execute() { 19 | if err := RootCmd.Execute(); err != nil { 20 | fmt.Println(err) 21 | os.Exit(1) 22 | } 23 | } 24 | 25 | func init() { 26 | cobra.OnInitialize(config.InitConfig) 27 | RootCmd.AddCommand(configCmd) 28 | } 29 | -------------------------------------------------------------------------------- /internal/provider/prompts.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import "github.com/m7medvision/lazycommit/internal/config" 4 | 5 | // GetCommitMessagePrompt returns the standardized prompt for generating commit messages 6 | func GetCommitMessagePrompt(diff string) string { 7 | return config.GetCommitMessagePromptFromConfig(diff) 8 | } 9 | 10 | // GetPRTitlePrompt returns the standardized prompt for generating pull request titles 11 | func GetPRTitlePrompt(diff string) string { 12 | return config.GetPRTitlePromptFromConfig(diff) 13 | } 14 | 15 | // GetSystemMessage returns the standardized system message for commit message generation 16 | func GetSystemMessage() string { 17 | return config.GetSystemMessageFromConfig() 18 | } 19 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | project_name: lazycommit 2 | 3 | release: 4 | prerelease: false 5 | github: 6 | owner: m7medVision 7 | name: lazycommit 8 | 9 | builds: 10 | - id: lazycommit 11 | main: ./main.go 12 | binary: lazycommit 13 | env: 14 | - CGO_ENABLED=0 15 | goos: [linux, darwin, windows] 16 | goarch: [amd64, arm64] 17 | ldflags: 18 | - -s -w 19 | 20 | archives: 21 | - id: archive 22 | builds: [lazycommit] 23 | format_overrides: 24 | - goos: windows 25 | format: zip 26 | 27 | checksum: 28 | name_template: "checksums.txt" 29 | 30 | changelog: 31 | sort: asc 32 | use: git 33 | filters: 34 | exclude: 35 | - '^docs:' 36 | - '^test:' 37 | 38 | 39 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | workflow_dispatch: 8 | 9 | permissions: 10 | contents: write 11 | 12 | jobs: 13 | goreleaser: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 0 20 | 21 | - name: Set up Go 22 | uses: actions/setup-go@v4 23 | with: 24 | go-version: "1.21" 25 | 26 | - name: Run GoReleaser 27 | uses: goreleaser/goreleaser-action@v5 28 | with: 29 | distribution: goreleaser 30 | version: latest 31 | args: release --clean 32 | env: 33 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.dll 4 | *.so 5 | *.dylib 6 | 7 | # Test binary, build artifacts, and coverage reports 8 | *.test 9 | *.out 10 | *.a 11 | *.o 12 | *.lo 13 | *.la 14 | *.lai 15 | *.gch 16 | *.gcda 17 | *.gcno 18 | *.gcov 19 | 20 | # Output of the go coverage tool 21 | *.cover 22 | 23 | # Go workspace file 24 | *.code-workspace 25 | 26 | # Dependency directories (vendor/) and Go modules 27 | vendor/ 28 | Gopkg.lock 29 | Gopkg.toml 30 | 31 | # IDE and editor files 32 | .idea/ 33 | .vscode/ 34 | *.swp 35 | *.swo 36 | 37 | # Logs and temporary files 38 | *.log 39 | *.tmp 40 | *.bak 41 | *.old 42 | 43 | # OS generated files 44 | .DS_Store 45 | Thumbs.db 46 | 47 | # Environment files 48 | .env 49 | .env.* 50 | 51 | # Build directories 52 | bin/ 53 | obj/ 54 | 55 | # Lazycommit specific 56 | lazycommit 57 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for lazycommit 2 | 3 | BINARY=lazycommit 4 | VERSION=0.1.0 5 | 6 | # Build the application 7 | build: 8 | go build -o ${BINARY} main.go 9 | 10 | # Install the application 11 | install: 12 | go install 13 | 14 | # Run tests 15 | test: 16 | go test -v ./... 17 | 18 | # Clean build artifacts 19 | clean: 20 | rm -f ${BINARY} 21 | 22 | # Build for multiple platforms 23 | build-all: build-linux build-mac build-windows 24 | 25 | build-linux: 26 | GOOS=linux GOARCH=amd64 go build -o ${BINARY}-linux-amd64 main.go 27 | 28 | build-mac: 29 | GOOS=darwin GOARCH=amd64 go build -o ${BINARY}-darwin-amd64 main.go 30 | 31 | build-windows: 32 | GOOS=windows GOARCH=amd64 go build -o ${BINARY}-windows-amd64.exe main.go 33 | 34 | # Format source code 35 | fmt: 36 | go fmt ./... 37 | 38 | # Run vet 39 | vet: 40 | go vet ./... 41 | 42 | .PHONY: build install test clean build-all build-linux build-mac build-windows fmt vet -------------------------------------------------------------------------------- /internal/git/git.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | 7 | "os/exec" 8 | ) 9 | 10 | // GetStagedDiff returns the diff of the staged files. 11 | func GetStagedDiff() (string, error) { 12 | cmd := exec.Command("git", "diff", "--cached") 13 | var out bytes.Buffer 14 | cmd.Stdout = &out 15 | err := cmd.Run() 16 | if err != nil { 17 | return "", fmt.Errorf("error running git diff --cached: %w", err) 18 | } 19 | return out.String(), nil 20 | } 21 | 22 | // GetDiffAgainstBranch returns the diff against the specified branch. For example "main" when creating a PR. 23 | func GetDiffAgainstBranch(branch string) (string, error) { 24 | // Check if the branch exists 25 | checkCmd := exec.Command("git", "rev-parse", "--verify", branch) 26 | if err := checkCmd.Run(); err != nil { 27 | return "", fmt.Errorf("branch '%s' does not exist", branch) 28 | } 29 | 30 | cmd := exec.Command("git", "diff", branch) 31 | var out bytes.Buffer 32 | cmd.Stdout = &out 33 | err := cmd.Run() 34 | if err != nil { 35 | return "", fmt.Errorf("error running git diff %s: %w", branch, err) 36 | } 37 | return out.String(), nil 38 | } 39 | 40 | // GetWorkingTreeDiff returns the diff of the working tree. 41 | func GetWorkingTreeDiff() (string, error) { 42 | cmd := exec.Command("git", "diff") 43 | var out bytes.Buffer 44 | cmd.Stdout = &out 45 | err := cmd.Run() 46 | if err != nil { 47 | return "", fmt.Errorf("error running git diff: %w", err) 48 | } 49 | return out.String(), nil 50 | } 51 | -------------------------------------------------------------------------------- /internal/provider/openai.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/openai/openai-go" 8 | "github.com/openai/openai-go/option" 9 | ) 10 | 11 | type OpenAIProvider struct { 12 | commonProvider 13 | } 14 | 15 | func NewOpenAIProvider(apiKey, model, endpoint string) *OpenAIProvider { 16 | if model == "" { 17 | model = "gpt-5-mini" 18 | } 19 | 20 | // Set default endpoint if none provided 21 | if endpoint == "" { 22 | endpoint = "https://api.openai.com/v1" 23 | } 24 | 25 | client := openai.NewClient( 26 | option.WithBaseURL(endpoint), 27 | option.WithAPIKey(apiKey), 28 | ) 29 | return &OpenAIProvider{ 30 | commonProvider: commonProvider{ 31 | client: &client, 32 | model: model, 33 | }, 34 | } 35 | } 36 | 37 | func (o *OpenAIProvider) GenerateCommitMessage(ctx context.Context, diff string) (string, error) { 38 | messages, err := o.generateCommitMessages(ctx, diff) 39 | if err != nil { 40 | return "", err 41 | } 42 | if len(messages) == 0 { 43 | return "", fmt.Errorf("no commit messages generated") 44 | } 45 | return messages[0], nil 46 | } 47 | 48 | func (o *OpenAIProvider) GenerateCommitMessages(ctx context.Context, diff string) ([]string, error) { 49 | return o.generateCommitMessages(ctx, diff) 50 | } 51 | 52 | func (o *OpenAIProvider) GeneratePRTitle(ctx context.Context, diff string) (string, error) { 53 | titles, err := o.generatePRTitles(ctx, diff) 54 | if err != nil { 55 | return "", err 56 | } 57 | if len(titles) == 0 { 58 | return "", fmt.Errorf("no PR titles generated") 59 | } 60 | return titles[0], nil 61 | } 62 | 63 | func (o *OpenAIProvider) GeneratePRTitles(ctx context.Context, diff string) ([]string, error) { 64 | return o.generatePRTitles(ctx, diff) 65 | } 66 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/m7medvision/lazycommit 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.24.5 6 | 7 | require ( 8 | github.com/AlecAivazis/survey/v2 v2.3.7 9 | github.com/openai/openai-go v1.11.1 10 | github.com/spf13/cobra v1.9.1 11 | github.com/spf13/viper v1.20.1 12 | ) 13 | 14 | require ( 15 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 16 | github.com/fsnotify/fsnotify v1.8.0 // indirect 17 | github.com/go-viper/mapstructure/v2 v2.4.0 // indirect 18 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 19 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect 20 | github.com/mattn/go-colorable v0.1.2 // indirect 21 | github.com/mattn/go-isatty v0.0.8 // indirect 22 | github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect 23 | github.com/pelletier/go-toml/v2 v2.2.3 // indirect 24 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 25 | github.com/rogpeppe/go-internal v1.11.0 // indirect 26 | github.com/sagikazarmark/locafero v0.7.0 // indirect 27 | github.com/sourcegraph/conc v0.3.0 // indirect 28 | github.com/spf13/afero v1.12.0 // indirect 29 | github.com/spf13/cast v1.7.1 // indirect 30 | github.com/spf13/pflag v1.0.7 // indirect 31 | github.com/subosito/gotenv v1.6.0 // indirect 32 | github.com/tidwall/gjson v1.18.0 // indirect 33 | github.com/tidwall/match v1.1.1 // indirect 34 | github.com/tidwall/pretty v1.2.1 // indirect 35 | github.com/tidwall/sjson v1.2.5 // indirect 36 | go.uber.org/atomic v1.9.0 // indirect 37 | go.uber.org/multierr v1.9.0 // indirect 38 | golang.org/x/sys v0.34.0 // indirect 39 | golang.org/x/term v0.33.0 // indirect 40 | golang.org/x/text v0.27.0 // indirect 41 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect 42 | gopkg.in/yaml.v3 v3.0.1 // indirect 43 | ) 44 | -------------------------------------------------------------------------------- /internal/provider/common.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/openai/openai-go" 9 | ) 10 | 11 | // commonProvider holds the common fields and methods for OpenAI-compatible providers. 12 | type commonProvider struct { 13 | client *openai.Client 14 | model string 15 | } 16 | 17 | // generateCommitMessages is a helper function to generate commit messages using the OpenAI API. 18 | func (c *commonProvider) generateCommitMessages(ctx context.Context, diff string) ([]string, error) { 19 | if diff == "" { 20 | return nil, fmt.Errorf("no diff provided") 21 | } 22 | 23 | params := openai.ChatCompletionNewParams{ 24 | Model: openai.ChatModel(c.model), 25 | Messages: []openai.ChatCompletionMessageParamUnion{ 26 | {OfSystem: &openai.ChatCompletionSystemMessageParam{Content: openai.ChatCompletionSystemMessageParamContentUnion{OfString: openai.String(GetSystemMessage())}}}, 27 | {OfUser: &openai.ChatCompletionUserMessageParam{Content: openai.ChatCompletionUserMessageParamContentUnion{OfString: openai.String(GetCommitMessagePrompt(diff))}}}, 28 | }, 29 | } 30 | 31 | resp, err := c.client.Chat.Completions.New(ctx, params) 32 | if err != nil { 33 | return nil, fmt.Errorf("error making request to OpenAI compatible API: %w", err) 34 | } 35 | 36 | if len(resp.Choices) == 0 { 37 | return nil, fmt.Errorf("no commit messages generated") 38 | } 39 | 40 | content := resp.Choices[0].Message.Content 41 | messages := strings.Split(content, "\n") 42 | var cleanMessages []string 43 | for _, msg := range messages { 44 | if strings.TrimSpace(msg) != "" { 45 | cleanMessages = append(cleanMessages, strings.TrimSpace(msg)) 46 | } 47 | } 48 | return cleanMessages, nil 49 | } 50 | 51 | // generatePRTitles is a helper function to generate pull request titles using the OpenAI API. 52 | func (c *commonProvider) generatePRTitles(ctx context.Context, diff string) ([]string, error) { 53 | if diff == "" { 54 | return nil, fmt.Errorf("no diff provided") 55 | } 56 | 57 | params := openai.ChatCompletionNewParams{ 58 | Model: openai.ChatModel(c.model), 59 | Messages: []openai.ChatCompletionMessageParamUnion{ 60 | {OfSystem: &openai.ChatCompletionSystemMessageParam{Content: openai.ChatCompletionSystemMessageParamContentUnion{OfString: openai.String(GetSystemMessage())}}}, 61 | {OfUser: &openai.ChatCompletionUserMessageParam{Content: openai.ChatCompletionUserMessageParamContentUnion{OfString: openai.String(GetPRTitlePrompt(diff))}}}, 62 | }, 63 | } 64 | 65 | resp, err := c.client.Chat.Completions.New(ctx, params) 66 | if err != nil { 67 | return nil, fmt.Errorf("error making request to OpenAI compatible API: %w", err) 68 | } 69 | 70 | if len(resp.Choices) == 0 { 71 | return nil, fmt.Errorf("no pr titles generated") 72 | } 73 | 74 | content := resp.Choices[0].Message.Content 75 | messages := strings.Split(content, "\n") 76 | var cleanMessages []string 77 | for _, msg := range messages { 78 | if strings.TrimSpace(msg) != "" { 79 | cleanMessages = append(cleanMessages, strings.TrimSpace(msg)) 80 | } 81 | } 82 | return cleanMessages, nil 83 | } 84 | -------------------------------------------------------------------------------- /cmd/commit.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/m7medvision/lazycommit/internal/config" 9 | "github.com/m7medvision/lazycommit/internal/git" 10 | "github.com/m7medvision/lazycommit/internal/provider" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | // CommitProvider defines the interface for generating commit messages 15 | type CommitProvider interface { 16 | GenerateCommitMessage(ctx context.Context, diff string) (string, error) 17 | GenerateCommitMessages(ctx context.Context, diff string) ([]string, error) 18 | } 19 | 20 | func init() { 21 | RootCmd.AddCommand(commitCmd) 22 | } 23 | 24 | var commitCmd = &cobra.Command{ 25 | Use: "commit", 26 | Short: "Generate commit message suggestions", 27 | Long: `Analyzes your staged changes and generates a list of 10 conventional commit message suggestions.`, 28 | Run: func(cmd *cobra.Command, args []string) { 29 | diff, err := git.GetStagedDiff() 30 | if err != nil { 31 | fmt.Fprintf(os.Stderr, "Error getting staged diff: %v\n", err) 32 | os.Exit(1) 33 | } 34 | 35 | if diff == "" { 36 | fmt.Println("No staged changes to commit.") 37 | return 38 | } 39 | 40 | var aiProvider CommitProvider 41 | 42 | providerName := config.GetProvider() 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 | } 53 | } 54 | 55 | var model string 56 | if providerName == "copilot" || providerName == "openai" || providerName == "anthropic" { 57 | var err error 58 | model, err = config.GetModel() 59 | if err != nil { 60 | fmt.Fprintf(os.Stderr, "Error getting model: %v\n", err) 61 | os.Exit(1) 62 | } 63 | } 64 | 65 | endpoint, err := config.GetEndpoint() 66 | if err != nil { 67 | fmt.Fprintf(os.Stderr, "Error getting endpoint: %v\n", err) 68 | os.Exit(1) 69 | } 70 | 71 | switch providerName { 72 | case "copilot": 73 | aiProvider = provider.NewCopilotProviderWithModel(apiKey, model, endpoint) 74 | case "openai": 75 | 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) 83 | default: 84 | // Default to copilot if provider is not set or unknown 85 | aiProvider = provider.NewCopilotProvider(apiKey, endpoint) 86 | } 87 | 88 | commitMessages, err := aiProvider.GenerateCommitMessages(context.Background(), diff) 89 | if err != nil { 90 | fmt.Fprintf(os.Stderr, "Error generating commit messages: %v\n", err) 91 | os.Exit(1) 92 | } 93 | 94 | if len(commitMessages) == 0 { 95 | fmt.Println("No commit messages generated.") 96 | return 97 | } 98 | 99 | for _, msg := range commitMessages { 100 | fmt.Println(msg) 101 | } 102 | }, 103 | } 104 | -------------------------------------------------------------------------------- /cmd/pr.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/m7medvision/lazycommit/internal/config" 9 | "github.com/m7medvision/lazycommit/internal/git" 10 | "github.com/m7medvision/lazycommit/internal/provider" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | // PrProvider defines the interface for generating pull request titles 15 | type PrProvider interface { 16 | GeneratePRTitle(ctx context.Context, diff string) (string, error) 17 | GeneratePRTitles(ctx context.Context, diff string) ([]string, error) 18 | } 19 | 20 | // prCmd represents the pr command 21 | var prCmd = &cobra.Command{ 22 | Use: "pr", 23 | Short: "Generate pull request title suggestions", 24 | Long: `Analyzes the diff of the current branch compared to a target branch, and generates a list of 10 suggested pull request titles. 25 | 26 | Arguments: 27 | The branch to compare against (e.g., main, develop)`, 28 | Args: func(cmd *cobra.Command, args []string) error { 29 | if len(args) < 1 { 30 | return fmt.Errorf("missing required argument: ") 31 | } 32 | if len(args) > 1 { 33 | return fmt.Errorf("too many arguments, expected 1 but got %d", len(args)) 34 | } 35 | return nil 36 | }, 37 | Example: "lazycommit pr main\n lazycommit pr develop", 38 | Run: func(cmd *cobra.Command, args []string) { 39 | diff, err := git.GetDiffAgainstBranch(args[0]) 40 | if err != nil { 41 | fmt.Fprintf(os.Stderr, "Error getting branch comparison diff: %v\n", err) 42 | os.Exit(1) 43 | } 44 | 45 | if diff == "" { 46 | fmt.Println("No changes compared to base branch.") 47 | return 48 | } 49 | 50 | var aiProvider PrProvider 51 | 52 | providerName := config.GetProvider() 53 | 54 | // API key is not needed for anthropic provider (uses CLI) 55 | var apiKey string 56 | if providerName != "anthropic" { 57 | var err error 58 | apiKey, err = config.GetAPIKey() 59 | if err != nil { 60 | fmt.Fprintf(os.Stderr, "Error getting API key: %v\n", err) 61 | os.Exit(1) 62 | } 63 | } 64 | 65 | var model string 66 | if providerName == "copilot" || providerName == "openai" || providerName == "anthropic" { 67 | var err error 68 | model, err = config.GetModel() 69 | if err != nil { 70 | fmt.Fprintf(os.Stderr, "Error getting model: %v\n", err) 71 | os.Exit(1) 72 | } 73 | } 74 | 75 | endpoint, err := config.GetEndpoint() 76 | if err != nil { 77 | fmt.Fprintf(os.Stderr, "Error getting endpoint: %v\n", err) 78 | os.Exit(1) 79 | } 80 | 81 | switch providerName { 82 | case "copilot": 83 | aiProvider = provider.NewCopilotProviderWithModel(apiKey, model, endpoint) 84 | case "openai": 85 | aiProvider = provider.NewOpenAIProvider(apiKey, model, endpoint) 86 | case "anthropic": 87 | // Get num_suggestions from config 88 | numSuggestions := config.GetNumSuggestions() 89 | aiProvider = provider.NewAnthropicProvider(model, numSuggestions) 90 | default: 91 | // Default to copilot if provider is not set or unknown 92 | aiProvider = provider.NewCopilotProvider(apiKey, endpoint) 93 | } 94 | 95 | prTitles, err := aiProvider.GeneratePRTitles(context.Background(), diff) 96 | if err != nil { 97 | fmt.Fprintf(os.Stderr, "Error generating pull request titles %v\n", err) 98 | os.Exit(1) 99 | } 100 | 101 | if len(prTitles) == 0 { 102 | fmt.Println("No PR titles generated.") 103 | return 104 | } 105 | 106 | for _, title := range prTitles { 107 | fmt.Println(title) 108 | } 109 | 110 | }, 111 | } 112 | 113 | func init() { 114 | RootCmd.AddCommand(prCmd) 115 | } 116 | -------------------------------------------------------------------------------- /internal/config/prompts.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | 8 | "gopkg.in/yaml.v3" 9 | ) 10 | 11 | type PromptConfig struct { 12 | SystemMessage string `yaml:"system_message"` 13 | CommitMessageTemplate string `yaml:"commit_message_template"` 14 | PRTitleTemplate string `yaml:"pr_title_template"` 15 | } 16 | 17 | var promptsCfg *PromptConfig 18 | 19 | // InitPromptConfig initializes the prompt configuration 20 | func InitPromptConfig() { 21 | if promptsCfg != nil { 22 | return 23 | } 24 | 25 | promptsFile := filepath.Join(getConfigDir(), ".lazycommit.prompts.yaml") 26 | 27 | // Check if prompts file exists 28 | if _, err := os.Stat(promptsFile); os.IsNotExist(err) { 29 | // Create default prompts file 30 | defaultConfig := getDefaultPromptConfig() 31 | if err := savePromptConfig(promptsFile, defaultConfig); err != nil { 32 | fmt.Printf("Error creating default prompts file: %v\n", err) 33 | fmt.Printf("Using default prompts\n") 34 | } else { 35 | fmt.Printf("Created default prompts config at %s\n", promptsFile) 36 | } 37 | promptsCfg = defaultConfig 38 | return 39 | } 40 | 41 | // Load existing prompts file 42 | data, err := os.ReadFile(promptsFile) 43 | if err != nil { 44 | fmt.Printf("Error reading prompts file: %v\n", err) 45 | fmt.Printf("Using default prompts\n") 46 | promptsCfg = getDefaultPromptConfig() 47 | return 48 | } 49 | 50 | var config PromptConfig 51 | if err := yaml.Unmarshal(data, &config); err != nil { 52 | fmt.Printf("Error parsing prompts file: %v\n", err) 53 | fmt.Printf("Using default prompts\n") 54 | promptsCfg = getDefaultPromptConfig() 55 | return 56 | } 57 | 58 | promptsCfg = &config 59 | } 60 | 61 | // getDefaultPromptConfig returns the default prompt configuration 62 | func getDefaultPromptConfig() *PromptConfig { 63 | return &PromptConfig{ 64 | SystemMessage: "You are a helpful assistant that generates git commit messages, and pull request titles.", 65 | CommitMessageTemplate: "Based on the following git diff, generate 10 conventional commit messages. Each message should be on a new line, without any numbering or bullet points:\n\n%s", 66 | PRTitleTemplate: "Based on the following git diff, generate 10 pull request title suggestions. Each title should be on a new line, without any numbering or bullet points:\n\n%s", 67 | } 68 | } 69 | 70 | // savePromptConfig saves the prompt configuration to a file 71 | func savePromptConfig(filename string, config *PromptConfig) error { 72 | data, err := yaml.Marshal(config) 73 | if err != nil { 74 | return fmt.Errorf("error marshalling prompt config: %w", err) 75 | } 76 | 77 | if err := os.WriteFile(filename, data, 0o644); err != nil { 78 | return fmt.Errorf("error writing prompt config file: %w", err) 79 | } 80 | 81 | return nil 82 | } 83 | 84 | // GetPromptConfig returns the current prompt configuration 85 | func GetPromptConfig() *PromptConfig { 86 | if promptsCfg == nil { 87 | InitPromptConfig() 88 | } 89 | return promptsCfg 90 | } 91 | 92 | // GetSystemMessageFromConfig returns the system message from configuration 93 | func GetSystemMessageFromConfig() string { 94 | config := GetPromptConfig() 95 | if config.SystemMessage != "" { 96 | return config.SystemMessage 97 | } 98 | // Fallback to hardcoded default 99 | return "You are a helpful assistant that generates git commit messages." 100 | } 101 | 102 | // GetCommitMessagePromptFromConfig returns the commit message prompt from configuration 103 | func GetCommitMessagePromptFromConfig(diff string) string { 104 | config := GetPromptConfig() 105 | var basePrompt string 106 | if config.CommitMessageTemplate != "" { 107 | basePrompt = fmt.Sprintf(config.CommitMessageTemplate, diff) 108 | } else { 109 | // Fallback to hardcoded default 110 | basePrompt = fmt.Sprintf("Based on the following git diff, generate 10 conventional commit messages. Each message should be on a new line, without any numbering or bullet points:\n\n%s", diff) 111 | } 112 | 113 | // Add language instruction based on configuration 114 | language := GetLanguage() 115 | if language == "es" { 116 | basePrompt += "\n\nIMPORTANT: Generate all commit messages in Spanish." 117 | } else if language == "en" { 118 | basePrompt += "\n\nIMPORTANT: Generate all commit messages in English." 119 | } 120 | 121 | return basePrompt 122 | } 123 | 124 | // GetPRTitlePromptFromConfig returns the pull request title prompt from configuration 125 | func GetPRTitlePromptFromConfig(diff string) string { 126 | config := GetPromptConfig() 127 | if config.PRTitleTemplate != "" { 128 | return fmt.Sprintf(config.PRTitleTemplate, diff) 129 | } 130 | // Fallback to hardcoded default 131 | return fmt.Sprintf("Based on the following git diff, generate 10 pull request title suggestions. Each title should be on a new line, without any numbering or bullet points:\n\n%s", diff) 132 | } 133 | -------------------------------------------------------------------------------- /internal/provider/anthropic.go: -------------------------------------------------------------------------------- 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 | } 112 | 113 | func (a *AnthropicProvider) GeneratePRTitle(ctx context.Context, diff string) (string, error) { 114 | titles, err := a.GeneratePRTitles(ctx, diff) 115 | if err != nil { 116 | return "", err 117 | } 118 | if len(titles) == 0 { 119 | return "", fmt.Errorf("no PR titles generated") 120 | } 121 | return titles[0], nil 122 | } 123 | 124 | func (a *AnthropicProvider) GeneratePRTitles(ctx context.Context, diff string) ([]string, error) { 125 | if strings.TrimSpace(diff) == "" { 126 | return nil, fmt.Errorf("no diff provided") 127 | } 128 | 129 | // Check if claude CLI is available 130 | if _, err := exec.LookPath("claude"); err != nil { 131 | return nil, fmt.Errorf("claude CLI not found in PATH. Please install Claude Code CLI: %w", err) 132 | } 133 | 134 | // Build the prompt using PR title template 135 | systemMsg := GetSystemMessage() 136 | userPrompt := GetPRTitlePrompt(diff) 137 | 138 | // Modify the prompt to request specific number of suggestions 139 | 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.", 140 | systemMsg, userPrompt, a.numSuggestions) 141 | 142 | // Execute claude CLI with the specified model 143 | cmd := exec.CommandContext(ctx, "claude", "--model", a.model, "-p", fullPrompt) 144 | 145 | output, err := cmd.CombinedOutput() 146 | if err != nil { 147 | return nil, fmt.Errorf("error executing claude CLI: %w\nOutput: %s", err, string(output)) 148 | } 149 | 150 | // Parse the output - same logic as commit message generation 151 | content := string(output) 152 | lines := strings.Split(content, "\n") 153 | 154 | var prTitles []string 155 | for _, line := range lines { 156 | trimmed := strings.TrimSpace(line) 157 | if trimmed == "" { 158 | continue 159 | } 160 | if len(trimmed) > 200 { 161 | continue 162 | } 163 | // Skip markdown formatting or numbered lists 164 | if strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, "-") || strings.HasPrefix(trimmed, "*") { 165 | parts := strings.SplitN(trimmed, " ", 2) 166 | if len(parts) == 2 { 167 | trimmed = strings.TrimSpace(parts[1]) 168 | } 169 | } 170 | // Remove numbered list formatting like "1. " or "1) " 171 | if len(trimmed) > 3 { 172 | if (trimmed[0] >= '0' && trimmed[0] <= '9') && (trimmed[1] == '.' || trimmed[1] == ')') { 173 | trimmed = strings.TrimSpace(trimmed[2:]) 174 | } 175 | } 176 | 177 | if trimmed != "" { 178 | prTitles = append(prTitles, trimmed) 179 | } 180 | 181 | // Stop once we have enough titles 182 | if len(prTitles) >= a.numSuggestions { 183 | break 184 | } 185 | } 186 | 187 | if len(prTitles) == 0 { 188 | return nil, fmt.Errorf("no valid PR titles generated from Claude output") 189 | } 190 | 191 | return prTitles, nil 192 | } 193 | -------------------------------------------------------------------------------- /internal/provider/models/models.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | // ModelProvider represents the provider of the model 4 | type ModelProvider string 5 | 6 | // ModelID represents the unique identifier for a model 7 | type ModelID string 8 | 9 | // Model represents the details of a model 10 | type Model struct { 11 | ID ModelID 12 | Name string 13 | Provider ModelProvider 14 | APIModel string 15 | CostPer1MIn float64 16 | CostPer1MInCached float64 17 | CostPer1MOut float64 18 | CostPer1MOutCached float64 19 | ContextWindow int 20 | DefaultMaxTokens int 21 | CanReason bool 22 | SupportsAttachments bool 23 | } 24 | 25 | const ( 26 | ProviderOpenAI ModelProvider = "openai" 27 | ProviderAnthropic ModelProvider = "anthropic" 28 | 29 | GPT41 ModelID = "gpt-4.1" 30 | GPT41Mini ModelID = "gpt-4.1-mini" 31 | GPT41Nano ModelID = "gpt-4.1-nano" 32 | GPT45Preview ModelID = "gpt-4.5-preview" 33 | GPT4o ModelID = "gpt-4o" 34 | GPT4oMini ModelID = "gpt-4o-mini" 35 | O1 ModelID = "o1" 36 | O1Pro ModelID = "o1-pro" 37 | O1Mini ModelID = "o1-mini" 38 | O3 ModelID = "o3" 39 | O3Mini ModelID = "o3-mini" 40 | O4Mini ModelID = "o4-mini" 41 | 42 | ClaudeHaiku45 ModelID = "claude-haiku-4-5" 43 | ) 44 | 45 | var OpenAIModels = map[ModelID]Model{ 46 | GPT41: { 47 | ID: GPT41, 48 | Name: "GPT 4.1", 49 | Provider: ProviderOpenAI, 50 | APIModel: "gpt-4.1", 51 | CostPer1MIn: 2.00, 52 | CostPer1MInCached: 0.50, 53 | CostPer1MOutCached: 0.0, 54 | CostPer1MOut: 8.00, 55 | ContextWindow: 1_047_576, 56 | DefaultMaxTokens: 20000, 57 | SupportsAttachments: true, 58 | }, 59 | GPT41Mini: { 60 | ID: GPT41Mini, 61 | Name: "GPT 4.1 mini", 62 | Provider: ProviderOpenAI, 63 | APIModel: "gpt-4.1", 64 | CostPer1MIn: 0.40, 65 | CostPer1MInCached: 0.10, 66 | CostPer1MOutCached: 0.0, 67 | CostPer1MOut: 1.60, 68 | ContextWindow: 200_000, 69 | DefaultMaxTokens: 20000, 70 | SupportsAttachments: true, 71 | }, 72 | GPT41Nano: { 73 | ID: GPT41Nano, 74 | Name: "GPT 4.1 nano", 75 | Provider: ProviderOpenAI, 76 | APIModel: "gpt-4.1-nano", 77 | CostPer1MIn: 0.10, 78 | CostPer1MInCached: 0.025, 79 | CostPer1MOutCached: 0.0, 80 | CostPer1MOut: 0.40, 81 | ContextWindow: 1_047_576, 82 | DefaultMaxTokens: 20000, 83 | SupportsAttachments: true, 84 | }, 85 | GPT45Preview: { 86 | ID: GPT45Preview, 87 | Name: "GPT 4.5 preview", 88 | Provider: ProviderOpenAI, 89 | APIModel: "gpt-4.5-preview", 90 | CostPer1MIn: 75.00, 91 | CostPer1MInCached: 37.50, 92 | CostPer1MOutCached: 0.0, 93 | CostPer1MOut: 150.00, 94 | ContextWindow: 128_000, 95 | DefaultMaxTokens: 15000, 96 | SupportsAttachments: true, 97 | }, 98 | GPT4o: { 99 | ID: GPT4o, 100 | Name: "GPT 4o", 101 | Provider: ProviderOpenAI, 102 | APIModel: "gpt-4o", 103 | CostPer1MIn: 2.50, 104 | CostPer1MInCached: 1.25, 105 | CostPer1MOutCached: 0.0, 106 | CostPer1MOut: 10.00, 107 | ContextWindow: 128_000, 108 | DefaultMaxTokens: 4096, 109 | SupportsAttachments: true, 110 | }, 111 | GPT4oMini: { 112 | ID: GPT4oMini, 113 | Name: "GPT 4o mini", 114 | Provider: ProviderOpenAI, 115 | APIModel: "gpt-4o-mini", 116 | CostPer1MIn: 0.15, 117 | CostPer1MInCached: 0.075, 118 | CostPer1MOutCached: 0.0, 119 | CostPer1MOut: 0.60, 120 | ContextWindow: 128_000, 121 | SupportsAttachments: true, 122 | }, 123 | O1: { 124 | ID: O1, 125 | Name: "O1", 126 | Provider: ProviderOpenAI, 127 | APIModel: "o1", 128 | CostPer1MIn: 15.00, 129 | CostPer1MInCached: 7.50, 130 | CostPer1MOutCached: 0.0, 131 | CostPer1MOut: 60.00, 132 | ContextWindow: 200_000, 133 | DefaultMaxTokens: 50000, 134 | CanReason: true, 135 | SupportsAttachments: true, 136 | }, 137 | O1Pro: { 138 | ID: O1Pro, 139 | Name: "o1 pro", 140 | Provider: ProviderOpenAI, 141 | APIModel: "o1-pro", 142 | CostPer1MIn: 150.00, 143 | CostPer1MInCached: 0.0, 144 | CostPer1MOutCached: 0.0, 145 | CostPer1MOut: 600.00, 146 | ContextWindow: 200_000, 147 | DefaultMaxTokens: 50000, 148 | CanReason: true, 149 | SupportsAttachments: true, 150 | }, 151 | O1Mini: { 152 | ID: O1Mini, 153 | Name: "o1 mini", 154 | Provider: ProviderOpenAI, 155 | APIModel: "o1-mini", 156 | CostPer1MIn: 1.10, 157 | CostPer1MInCached: 0.55, 158 | CostPer1MOutCached: 0.0, 159 | CostPer1MOut: 4.40, 160 | ContextWindow: 128_000, 161 | DefaultMaxTokens: 50000, 162 | CanReason: true, 163 | SupportsAttachments: true, 164 | }, 165 | O3: { 166 | ID: O3, 167 | Name: "o3", 168 | Provider: ProviderOpenAI, 169 | APIModel: "o3", 170 | CostPer1MIn: 10.00, 171 | CostPer1MInCached: 2.50, 172 | CostPer1MOutCached: 0.0, 173 | CostPer1MOut: 40.00, 174 | ContextWindow: 200_000, 175 | CanReason: true, 176 | SupportsAttachments: true, 177 | }, 178 | O3Mini: { 179 | ID: O3Mini, 180 | Name: "o3 mini", 181 | Provider: ProviderOpenAI, 182 | APIModel: "o3-mini", 183 | CostPer1MIn: 1.10, 184 | CostPer1MInCached: 0.55, 185 | CostPer1MOutCached: 0.0, 186 | CostPer1MOut: 4.40, 187 | ContextWindow: 200_000, 188 | DefaultMaxTokens: 50000, 189 | CanReason: true, 190 | SupportsAttachments: false, 191 | }, 192 | O4Mini: { 193 | ID: O4Mini, 194 | Name: "o4 mini", 195 | Provider: ProviderOpenAI, 196 | APIModel: "o4-mini", 197 | CostPer1MIn: 1.10, 198 | CostPer1MInCached: 0.275, 199 | CostPer1MOutCached: 0.0, 200 | CostPer1MOut: 4.40, 201 | ContextWindow: 128_000, 202 | DefaultMaxTokens: 50000, 203 | CanReason: true, 204 | SupportsAttachments: true, 205 | }, 206 | } 207 | 208 | var AnthropicModels = map[ModelID]Model{ 209 | ClaudeHaiku45: { 210 | ID: ClaudeHaiku45, 211 | Name: "Claude Haiku 4.5", 212 | Provider: ProviderAnthropic, 213 | APIModel: "claude-haiku-4-5", 214 | CostPer1MIn: 0.80, 215 | CostPer1MInCached: 0.08, 216 | CostPer1MOut: 4.00, 217 | CostPer1MOutCached: 0.40, 218 | ContextWindow: 200_000, 219 | DefaultMaxTokens: 8192, 220 | SupportsAttachments: true, 221 | }, 222 | } 223 | -------------------------------------------------------------------------------- /internal/config/config_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/spf13/viper" 8 | ) 9 | 10 | func TestGetAPIKey_EnvironmentVariable(t *testing.T) { 11 | // Reset configuration for clean test 12 | cfg = nil 13 | viper.Reset() 14 | 15 | // Set up test environment variable 16 | testEnvVar := "TEST_API_KEY" 17 | testAPIKey := "test-api-key-value-123" 18 | os.Setenv(testEnvVar, testAPIKey) 19 | defer os.Unsetenv(testEnvVar) 20 | 21 | // Initialize config 22 | InitConfig() 23 | 24 | // Set up test provider with environment variable reference 25 | testProvider := "openrouter" 26 | cfg.ActiveProvider = testProvider 27 | if cfg.Providers == nil { 28 | cfg.Providers = make(map[string]ProviderConfig) 29 | } 30 | cfg.Providers[testProvider] = ProviderConfig{ 31 | APIKey: "$" + testEnvVar, 32 | Model: "test-model", 33 | } 34 | 35 | // Test that environment variable is resolved 36 | resolvedKey, err := GetAPIKey() 37 | if err != nil { 38 | t.Fatalf("Expected no error, got: %v", err) 39 | } 40 | 41 | if resolvedKey != testAPIKey { 42 | t.Errorf("Expected resolved API key to be %s, got %s", testAPIKey, resolvedKey) 43 | } 44 | } 45 | 46 | func TestGetAPIKey_EnvironmentVariableNotSet(t *testing.T) { 47 | // Reset configuration for clean test 48 | cfg = nil 49 | viper.Reset() 50 | 51 | // Initialize config 52 | InitConfig() 53 | 54 | // Set up test provider with environment variable reference that doesn't exist 55 | testProvider := "openai" 56 | cfg.ActiveProvider = testProvider 57 | if cfg.Providers == nil { 58 | cfg.Providers = make(map[string]ProviderConfig) 59 | } 60 | cfg.Providers[testProvider] = ProviderConfig{ 61 | APIKey: "$NONEXISTENT_API_KEY", 62 | Model: "test-model", 63 | } 64 | 65 | // Test that missing environment variable returns error 66 | _, err := GetAPIKey() 67 | if err == nil { 68 | t.Fatal("Expected error for missing environment variable, got nil") 69 | } 70 | 71 | expectedError := "environment variable 'NONEXISTENT_API_KEY' for provider 'openai' is not set or empty" 72 | if err.Error() != expectedError { 73 | t.Errorf("Expected error message '%s', got '%s'", expectedError, err.Error()) 74 | } 75 | } 76 | 77 | func TestGetAPIKey_RegularAPIKey(t *testing.T) { 78 | // Reset configuration for clean test 79 | cfg = nil 80 | viper.Reset() 81 | 82 | // Initialize config 83 | InitConfig() 84 | 85 | // Set up test provider with regular API key (not environment variable) 86 | testProvider := "openai" 87 | testAPIKey := "regular-api-key-123" 88 | cfg.ActiveProvider = testProvider 89 | if cfg.Providers == nil { 90 | cfg.Providers = make(map[string]ProviderConfig) 91 | } 92 | cfg.Providers[testProvider] = ProviderConfig{ 93 | APIKey: testAPIKey, 94 | Model: "test-model", 95 | } 96 | 97 | // Test that regular API key is returned as-is 98 | resolvedKey, err := GetAPIKey() 99 | if err != nil { 100 | t.Fatalf("Expected no error, got: %v", err) 101 | } 102 | 103 | if resolvedKey != testAPIKey { 104 | t.Errorf("Expected API key to be %s, got %s", testAPIKey, resolvedKey) 105 | } 106 | } 107 | 108 | func TestGetEndpoint_DefaultEndpoints(t *testing.T) { 109 | // Reset configuration for clean test 110 | cfg = nil 111 | viper.Reset() 112 | 113 | // Test default endpoints for different providers 114 | testCases := []struct { 115 | provider string 116 | expected string 117 | }{ 118 | {"openai", "https://api.openai.com/v1"}, 119 | {"copilot", "https://api.githubcopilot.com"}, 120 | } 121 | 122 | for _, tc := range testCases { 123 | // Initialize config 124 | InitConfig() 125 | 126 | // Set up test provider without custom endpoint 127 | cfg.ActiveProvider = tc.provider 128 | if cfg.Providers == nil { 129 | cfg.Providers = make(map[string]ProviderConfig) 130 | } 131 | cfg.Providers[tc.provider] = ProviderConfig{ 132 | APIKey: "test-key", 133 | Model: "test-model", 134 | // No EndpointURL set - should use default 135 | } 136 | 137 | // Test that default endpoint is returned 138 | endpoint, err := GetEndpoint() 139 | if err != nil { 140 | t.Fatalf("Expected no error for provider %s, got: %v", tc.provider, err) 141 | } 142 | 143 | if endpoint != tc.expected { 144 | t.Errorf("Expected endpoint %s for provider %s, got %s", tc.expected, tc.provider, endpoint) 145 | } 146 | } 147 | } 148 | 149 | func TestGetEndpoint_CustomEndpoint(t *testing.T) { 150 | // Reset configuration for clean test 151 | cfg = nil 152 | viper.Reset() 153 | 154 | // Initialize config 155 | InitConfig() 156 | 157 | // Set up test provider with custom endpoint 158 | testProvider := "openai" 159 | customEndpoint := "https://custom.api.com/v1" 160 | cfg.ActiveProvider = testProvider 161 | if cfg.Providers == nil { 162 | cfg.Providers = make(map[string]ProviderConfig) 163 | } 164 | cfg.Providers[testProvider] = ProviderConfig{ 165 | APIKey: "test-key", 166 | Model: "test-model", 167 | EndpointURL: customEndpoint, 168 | } 169 | 170 | // Test that custom endpoint is returned 171 | endpoint, err := GetEndpoint() 172 | if err != nil { 173 | t.Fatalf("Expected no error, got: %v", err) 174 | } 175 | 176 | if endpoint != customEndpoint { 177 | t.Errorf("Expected custom endpoint %s, got %s", customEndpoint, endpoint) 178 | } 179 | } 180 | 181 | func TestGetEndpoint_UnknownProvider(t *testing.T) { 182 | // Reset configuration for clean test 183 | cfg = nil 184 | viper.Reset() 185 | 186 | // Initialize config 187 | InitConfig() 188 | 189 | // Set up unknown provider without custom endpoint 190 | testProvider := "unknown-provider" 191 | cfg.ActiveProvider = testProvider 192 | if cfg.Providers == nil { 193 | cfg.Providers = make(map[string]ProviderConfig) 194 | } 195 | cfg.Providers[testProvider] = ProviderConfig{ 196 | APIKey: "test-key", 197 | Model: "test-model", 198 | } 199 | 200 | // Test that unknown provider without custom endpoint returns error 201 | _, err := GetEndpoint() 202 | if err == nil { 203 | t.Fatal("Expected error for unknown provider, got nil") 204 | } 205 | 206 | expectedError := "no default endpoint available for provider 'unknown-provider'" 207 | if err.Error() != expectedError { 208 | t.Errorf("Expected error message '%s', got '%s'", expectedError, err.Error()) 209 | } 210 | } 211 | 212 | func TestSetEndpoint_Validation(t *testing.T) { 213 | // Reset configuration for clean test 214 | cfg = nil 215 | viper.Reset() 216 | 217 | // Initialize config 218 | InitConfig() 219 | 220 | testCases := []struct { 221 | endpoint string 222 | valid bool 223 | }{ 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 230 | } 231 | 232 | for _, tc := range testCases { 233 | err := SetEndpoint("test", tc.endpoint) 234 | if tc.valid && err != nil { 235 | t.Errorf("Expected valid endpoint %s to pass, but got error: %v", tc.endpoint, err) 236 | } else if !tc.valid && err == nil { 237 | t.Errorf("Expected invalid endpoint %s to fail, but it passed", tc.endpoint) 238 | } 239 | } 240 | } -------------------------------------------------------------------------------- /internal/provider/copilot.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "os" 9 | "runtime" 10 | "strings" 11 | "time" 12 | 13 | "github.com/openai/openai-go" 14 | "github.com/openai/openai-go/option" 15 | ) 16 | 17 | type CopilotProvider struct { 18 | apiKey string 19 | model string 20 | endpoint string 21 | httpClient *http.Client 22 | } 23 | 24 | func NewCopilotProvider(token, endpoint string) *CopilotProvider { 25 | if endpoint == "" { 26 | endpoint = "https://api.githubcopilot.com" 27 | } 28 | return &CopilotProvider{ 29 | apiKey: token, 30 | model: "gpt-4o", 31 | endpoint: endpoint, 32 | httpClient: &http.Client{Timeout: 30 * time.Second}, 33 | } 34 | } 35 | 36 | func NewCopilotProviderWithModel(token, model, endpoint string) *CopilotProvider { 37 | m := normalizeCopilotModel(model) 38 | if endpoint == "" { 39 | endpoint = "https://api.githubcopilot.com" 40 | } 41 | return &CopilotProvider{ 42 | apiKey: token, 43 | model: m, 44 | endpoint: endpoint, 45 | httpClient: &http.Client{Timeout: 30 * time.Second}, 46 | } 47 | } 48 | 49 | func normalizeCopilotModel(model string) string { 50 | m := strings.TrimSpace(model) 51 | if m == "" { 52 | return "gpt-4o" 53 | } 54 | if strings.Contains(m, "/") { 55 | parts := strings.SplitN(m, "/", 2) 56 | if len(parts) == 2 && parts[1] != "" { 57 | return parts[1] 58 | } 59 | } 60 | return m 61 | } 62 | 63 | func (c *CopilotProvider) exchangeGitHubToken(ctx context.Context, githubToken string) (string, error) { 64 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.github.com/copilot_internal/v2/token", nil) 65 | if err != nil { 66 | return "", fmt.Errorf("failed creating token request: %w", err) 67 | } 68 | req.Header.Set("Authorization", "Token "+githubToken) 69 | req.Header.Set("User-Agent", "lazycommit/1.0") 70 | 71 | resp, err := c.httpClient.Do(req) 72 | if err != nil { 73 | return "", fmt.Errorf("failed exchanging token: %w", err) 74 | } 75 | defer resp.Body.Close() 76 | if resp.StatusCode != http.StatusOK { 77 | var body struct { 78 | Message string `json:"message"` 79 | } 80 | _ = json.NewDecoder(resp.Body).Decode(&body) 81 | return "", fmt.Errorf("token exchange failed: %d %s", resp.StatusCode, body.Message) 82 | } 83 | var tr struct { 84 | Token string `json:"token"` 85 | ExpiresAt int64 `json:"expires_at"` 86 | } 87 | if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil { 88 | return "", fmt.Errorf("failed decoding token response: %w", err) 89 | } 90 | if tr.Token == "" { 91 | return "", fmt.Errorf("empty copilot bearer token") 92 | } 93 | return tr.Token, nil 94 | } 95 | 96 | func (c *CopilotProvider) getGitHubToken() string { 97 | if c.apiKey != "" { 98 | return c.apiKey 99 | } 100 | if t := os.Getenv("GITHUB_TOKEN"); t != "" { 101 | return t 102 | } 103 | return "" 104 | } 105 | 106 | func (c *CopilotProvider) GenerateCommitMessage(ctx context.Context, diff string) (string, error) { 107 | msgs, err := c.GenerateCommitMessages(ctx, diff) 108 | if err != nil { 109 | return "", err 110 | } 111 | if len(msgs) == 0 { 112 | return "", fmt.Errorf("no commit messages generated") 113 | } 114 | return msgs[0], nil 115 | } 116 | 117 | func (c *CopilotProvider) GenerateCommitMessages(ctx context.Context, diff string) ([]string, error) { 118 | if strings.TrimSpace(diff) == "" { 119 | return nil, fmt.Errorf("no diff provided") 120 | } 121 | githubToken := c.getGitHubToken() 122 | if githubToken == "" { 123 | return nil, fmt.Errorf("GitHub token is required for Copilot provider") 124 | } 125 | 126 | var bearer string 127 | var err error 128 | 129 | // On Windows, use the token directly; on other platforms, exchange it for a Copilot token 130 | if runtime.GOOS == "windows" { 131 | bearer = githubToken 132 | } else { 133 | bearer, err = c.exchangeGitHubToken(ctx, githubToken) 134 | if err != nil { 135 | return nil, err 136 | } 137 | } 138 | 139 | 140 | client := openai.NewClient( 141 | option.WithBaseURL(c.endpoint), 142 | option.WithAPIKey(bearer), 143 | option.WithHeader("Editor-Version", "lazycommit/1.0"), 144 | option.WithHeader("Editor-Plugin-Version", "lazycommit/1.0"), 145 | option.WithHeader("Copilot-Integration-Id", "vscode-chat"), 146 | ) 147 | 148 | params := openai.ChatCompletionNewParams{ 149 | Model: openai.ChatModel(c.model), 150 | Messages: []openai.ChatCompletionMessageParamUnion{ 151 | {OfSystem: &openai.ChatCompletionSystemMessageParam{Content: openai.ChatCompletionSystemMessageParamContentUnion{OfString: openai.String(GetSystemMessage())}}}, 152 | {OfUser: &openai.ChatCompletionUserMessageParam{Content: openai.ChatCompletionUserMessageParamContentUnion{OfString: openai.String(GetCommitMessagePrompt(diff))}}}, 153 | }, 154 | } 155 | 156 | resp, err := client.Chat.Completions.New(ctx, params) 157 | if err != nil { 158 | return nil, fmt.Errorf("error making request to Copilot: %w", err) 159 | } 160 | if len(resp.Choices) == 0 { 161 | return nil, fmt.Errorf("no commit messages generated") 162 | } 163 | content := resp.Choices[0].Message.Content 164 | parts := strings.Split(content, "\n") 165 | var out []string 166 | for _, p := range parts { 167 | if s := strings.TrimSpace(p); s != "" { 168 | out = append(out, s) 169 | } 170 | } 171 | if len(out) == 0 { 172 | return nil, fmt.Errorf("no valid commit messages generated") 173 | } 174 | return out, nil 175 | } 176 | 177 | func (c *CopilotProvider) GeneratePRTitle(ctx context.Context, diff string) (string, error) { 178 | titles, err := c.GeneratePRTitles(ctx, diff) 179 | if err != nil { 180 | return "", err 181 | } 182 | if len(titles) == 0 { 183 | return "", fmt.Errorf("no PR titles generated") 184 | } 185 | return titles[0], nil 186 | } 187 | 188 | func (c *CopilotProvider) GeneratePRTitles(ctx context.Context, diff string) ([]string, error) { 189 | if strings.TrimSpace(diff) == "" { 190 | return nil, fmt.Errorf("no diff provided") 191 | } 192 | githubToken := c.getGitHubToken() 193 | if githubToken == "" { 194 | return nil, fmt.Errorf("GitHub token is required for Copilot provider") 195 | } 196 | 197 | var bearer string 198 | var err error 199 | 200 | // On Windows, use the token directly; on other platforms, exchange it for a Copilot token 201 | if runtime.GOOS == "windows" { 202 | bearer = githubToken 203 | } else { 204 | bearer, err = c.exchangeGitHubToken(ctx, githubToken) 205 | if err != nil { 206 | return nil, err 207 | } 208 | } 209 | 210 | client := openai.NewClient( 211 | option.WithBaseURL(c.endpoint), 212 | option.WithAPIKey(bearer), 213 | option.WithHeader("Editor-Version", "lazycommit/1.0"), 214 | option.WithHeader("Editor-Plugin-Version", "lazycommit/1.0"), 215 | option.WithHeader("Copilot-Integration-Id", "vscode-chat"), 216 | ) 217 | 218 | params := openai.ChatCompletionNewParams{ 219 | Model: openai.ChatModel(c.model), 220 | Messages: []openai.ChatCompletionMessageParamUnion{ 221 | {OfSystem: &openai.ChatCompletionSystemMessageParam{Content: openai.ChatCompletionSystemMessageParamContentUnion{OfString: openai.String(GetSystemMessage())}}}, 222 | {OfUser: &openai.ChatCompletionUserMessageParam{Content: openai.ChatCompletionUserMessageParamContentUnion{OfString: openai.String(GetPRTitlePrompt(diff))}}}, 223 | }, 224 | } 225 | 226 | resp, err := client.Chat.Completions.New(ctx, params) 227 | if err != nil { 228 | return nil, fmt.Errorf("error making request to Copilot: %w", err) 229 | } 230 | if len(resp.Choices) == 0 { 231 | return nil, fmt.Errorf("no PR titles generated") 232 | } 233 | content := resp.Choices[0].Message.Content 234 | parts := strings.Split(content, "\n") 235 | var out []string 236 | for _, p := range parts { 237 | if s := strings.TrimSpace(p); s != "" { 238 | out = append(out, s) 239 | } 240 | } 241 | if len(out) == 0 { 242 | return nil, fmt.Errorf("no valid PR titles generated") 243 | } 244 | return out, nil 245 | } 246 | -------------------------------------------------------------------------------- /cmd/config.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "os" 7 | 8 | "github.com/AlecAivazis/survey/v2" 9 | "github.com/m7medvision/lazycommit/internal/config" 10 | "github.com/m7medvision/lazycommit/internal/provider/models" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | var configCmd = &cobra.Command{ 15 | Use: "config", 16 | Short: "Manage configuration for lazycommit", 17 | Long: `Configure the provider, model, and other settings for lazycommit.`, 18 | } 19 | 20 | var getCmd = &cobra.Command{ 21 | Use: "get", 22 | Short: "Get the current configuration", 23 | Run: func(cmd *cobra.Command, args []string) { 24 | provider := config.GetProvider() 25 | model, err := config.GetModel() 26 | if err != nil { 27 | fmt.Println("Error getting model:", err) 28 | os.Exit(1) 29 | } 30 | endpoint, err := config.GetEndpoint() 31 | if err != nil { 32 | fmt.Println("Error getting endpoint:", err) 33 | os.Exit(1) 34 | } 35 | language := config.GetLanguage() 36 | fmt.Printf("Active Provider: %s\n", provider) 37 | fmt.Printf("Model: %s\n", model) 38 | fmt.Printf("Endpoint: %s\n", endpoint) 39 | fmt.Printf("Language: %s\n", language) 40 | }, 41 | } 42 | 43 | var setCmd = &cobra.Command{ 44 | Use: "set", 45 | Short: "Set configuration values", 46 | Run: func(cmd *cobra.Command, args []string) { 47 | runInteractiveConfig() 48 | }, 49 | } 50 | 51 | func validateEndpointURL(val any) error { 52 | endpoint, ok := val.(string) 53 | if !ok { 54 | return fmt.Errorf("endpoint must be a string") 55 | } 56 | 57 | // Empty string is valid (uses default) 58 | if endpoint == "" { 59 | return nil 60 | } 61 | 62 | parsedURL, err := url.Parse(endpoint) 63 | if err != nil { 64 | return fmt.Errorf("invalid URL format: %w", err) 65 | } 66 | 67 | if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { 68 | return fmt.Errorf("endpoint must use http or https protocol") 69 | } 70 | 71 | if parsedURL.Host == "" { 72 | return fmt.Errorf("endpoint must have a valid host") 73 | } 74 | 75 | return nil 76 | } 77 | 78 | func runInteractiveConfig() { 79 | currentProvider := config.GetProvider() 80 | currentModel, _ := config.GetModel() 81 | 82 | providerPrompt := &survey.Select{ 83 | Message: "Choose a provider:", 84 | Options: []string{"openai", "copilot", "anthropic"}, 85 | Default: currentProvider, 86 | } 87 | var selectedProvider string 88 | err := survey.AskOne(providerPrompt, &selectedProvider) 89 | if err != nil { 90 | fmt.Println(err.Error()) 91 | return 92 | } 93 | 94 | if selectedProvider != currentProvider { 95 | err := config.SetProvider(selectedProvider) 96 | if err != nil { 97 | fmt.Printf("Error setting provider: %v\n", err) 98 | return 99 | } 100 | fmt.Printf("Provider set to: %s\n", selectedProvider) 101 | currentModel = "" 102 | } 103 | 104 | // Language configuration 105 | currentLanguage := config.GetLanguage() 106 | languagePrompt := &survey.Select{ 107 | Message: "Choose a language for commit messages:", 108 | Options: []string{"English (en)", "Español (es)"}, 109 | Default: func() string { 110 | if currentLanguage == "es" { 111 | return "Español (es)" 112 | } 113 | return "English (en)" 114 | }(), 115 | } 116 | var selectedLanguage string 117 | err = survey.AskOne(languagePrompt, &selectedLanguage) 118 | if err != nil { 119 | fmt.Println(err.Error()) 120 | return 121 | } 122 | 123 | // Extract language code from selection 124 | langCode := "en" 125 | if selectedLanguage == "Español (es)" { 126 | langCode = "es" 127 | } 128 | 129 | if langCode != currentLanguage { 130 | err := config.SetLanguage(langCode) 131 | if err != nil { 132 | fmt.Printf("Error setting language: %v\n", err) 133 | return 134 | } 135 | fmt.Printf("Language set to: %s\n", langCode) 136 | } 137 | 138 | // API key configuration - skip for copilot and anthropic 139 | if selectedProvider != "copilot" && selectedProvider != "anthropic" { 140 | apiKeyPrompt := &survey.Input{ 141 | Message: fmt.Sprintf("Enter API Key for %s:", selectedProvider), 142 | } 143 | var apiKey string 144 | err := survey.AskOne(apiKeyPrompt, &apiKey) 145 | if err != nil { 146 | fmt.Println(err.Error()) 147 | return 148 | } 149 | if apiKey != "" { 150 | err := config.SetAPIKey(selectedProvider, apiKey) 151 | if err != nil { 152 | fmt.Printf("Error setting API key: %v\n", err) 153 | return 154 | } 155 | fmt.Printf("API key for %s set.\n", selectedProvider) 156 | } 157 | } else if selectedProvider == "anthropic" { 158 | fmt.Println("Anthropic provider uses Claude Code CLI - no API key needed.") 159 | } 160 | 161 | // Dynamically generate available models 162 | availableModels := map[string][]string{ 163 | "openai": {}, 164 | "copilot": {"openai/gpt-5-mini"}, 165 | "anthropic": {}, 166 | } 167 | 168 | modelDisplayToID := map[string]string{} 169 | 170 | if selectedProvider == "openai" { 171 | for id, m := range models.OpenAIModels { 172 | display := fmt.Sprintf("%s (%s)", m.Name, string(id)) 173 | availableModels["openai"] = append(availableModels["openai"], display) 174 | modelDisplayToID[display] = string(id) 175 | } 176 | } else if selectedProvider == "anthropic" { 177 | for _, m := range models.AnthropicModels { 178 | display := fmt.Sprintf("%s (%s)", m.Name, m.APIModel) 179 | availableModels["anthropic"] = append(availableModels["anthropic"], display) 180 | modelDisplayToID[display] = m.APIModel 181 | } 182 | } 183 | 184 | modelPrompt := &survey.Select{ 185 | Message: "Choose a model:", 186 | Options: availableModels[selectedProvider], 187 | } 188 | 189 | // Try to set the default to the current model if possible 190 | isValidDefault := false 191 | currentDisplay := "" 192 | if selectedProvider == "openai" || selectedProvider == "anthropic" { 193 | for display, id := range modelDisplayToID { 194 | if id == currentModel || display == currentModel { 195 | isValidDefault = true 196 | currentDisplay = display 197 | break 198 | } 199 | } 200 | } else { 201 | for _, model := range availableModels[selectedProvider] { 202 | if model == currentModel { 203 | isValidDefault = true 204 | currentDisplay = model 205 | break 206 | } 207 | } 208 | } 209 | if isValidDefault { 210 | modelPrompt.Default = currentDisplay 211 | } 212 | 213 | var selectedDisplay string 214 | err = survey.AskOne(modelPrompt, &selectedDisplay) 215 | if err != nil { 216 | fmt.Println(err.Error()) 217 | return 218 | } 219 | 220 | selectedModel := selectedDisplay 221 | if selectedProvider == "openai" || selectedProvider == "anthropic" { 222 | selectedModel = modelDisplayToID[selectedDisplay] 223 | } 224 | 225 | if selectedModel != currentModel { 226 | err := config.SetModel(selectedModel) 227 | if err != nil { 228 | fmt.Printf("Error setting model: %v\n", err) 229 | return 230 | } 231 | fmt.Printf("Model set to: %s\n", selectedModel) 232 | } 233 | 234 | // Number of suggestions configuration for anthropic 235 | if selectedProvider == "anthropic" { 236 | numSuggestionsPrompt := &survey.Input{ 237 | Message: "Number of commit message suggestions (default: 10):", 238 | Default: "10", 239 | } 240 | var numSuggestions string 241 | err := survey.AskOne(numSuggestionsPrompt, &numSuggestions) 242 | if err != nil { 243 | fmt.Println(err.Error()) 244 | return 245 | } 246 | if numSuggestions != "" { 247 | err := config.SetNumSuggestions(selectedProvider, numSuggestions) 248 | if err != nil { 249 | fmt.Printf("Error setting num_suggestions: %v\n", err) 250 | return 251 | } 252 | fmt.Printf("Number of suggestions set to: %s\n", numSuggestions) 253 | } 254 | } 255 | 256 | // Get current endpoint 257 | currentEndpoint, _ := config.GetEndpoint() 258 | 259 | // Endpoint configuration prompt - skip for anthropic since it uses CLI 260 | if selectedProvider != "anthropic" { 261 | endpointPrompt := &survey.Input{ 262 | Message: "Enter custom endpoint URL (leave empty for default):", 263 | Default: currentEndpoint, 264 | } 265 | var endpoint string 266 | err = survey.AskOne(endpointPrompt, &endpoint, survey.WithValidator(validateEndpointURL)) 267 | if err != nil { 268 | fmt.Println(err.Error()) 269 | return 270 | } 271 | 272 | // Only set endpoint if it's different from current 273 | if endpoint != currentEndpoint && endpoint != "" { 274 | err := config.SetEndpoint(selectedProvider, endpoint) 275 | if err != nil { 276 | fmt.Printf("Error setting endpoint: %v\n", err) 277 | return 278 | } 279 | fmt.Printf("Endpoint set to: %s\n", endpoint) 280 | } else if endpoint == "" { 281 | fmt.Println("Using default endpoint for provider") 282 | } 283 | } 284 | } 285 | 286 | func init() { 287 | configCmd.AddCommand(getCmd) 288 | configCmd.AddCommand(setCmd) 289 | } 290 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/url" 7 | "os" 8 | "os/exec" 9 | "path/filepath" 10 | "runtime" 11 | "strings" 12 | 13 | "github.com/spf13/viper" 14 | ) 15 | 16 | type ProviderConfig struct { 17 | APIKey string `mapstructure:"api_key"` 18 | Model string `mapstructure:"model"` 19 | EndpointURL string `mapstructure:"endpoint_url"` 20 | NumSuggestions int `mapstructure:"num_suggestions"` 21 | } 22 | 23 | type Config struct { 24 | Providers map[string]ProviderConfig `mapstructure:"providers"` 25 | ActiveProvider string `mapstructure:"active_provider"` 26 | Language string `mapstructure:"language"` 27 | } 28 | 29 | var cfg *Config 30 | 31 | func InitConfig() { 32 | viper.SetConfigName(".lazycommit") 33 | viper.SetConfigType("yaml") 34 | viper.AddConfigPath(getConfigDir()) 35 | viper.SetConfigFile(filepath.Join(getConfigDir(), ".lazycommit.yaml")) 36 | 37 | if token, err := LoadGitHubToken(); err == nil && token != "" { 38 | viper.SetDefault("active_provider", "copilot") 39 | viper.SetDefault("providers.copilot.api_key", token) 40 | viper.SetDefault("providers.copilot.model", "openai/gpt-5-mini") 41 | } else { 42 | viper.SetDefault("active_provider", "openai") 43 | viper.SetDefault("providers.openai.model", "openai/gpt-5-mini") 44 | } 45 | 46 | // Set defaults for anthropic provider 47 | viper.SetDefault("providers.anthropic.model", "claude-haiku-4-5") 48 | viper.SetDefault("providers.anthropic.num_suggestions", 10) 49 | 50 | // Set default language 51 | viper.SetDefault("language", "en") 52 | 53 | viper.AutomaticEnv() 54 | 55 | if err := viper.ReadInConfig(); err != nil { 56 | if _, ok := err.(viper.ConfigFileNotFoundError); ok { 57 | cfgDir := getConfigDir() 58 | _ = os.MkdirAll(cfgDir, 0o755) 59 | cfgPath := filepath.Join(cfgDir, ".lazycommit.yaml") 60 | if writeErr := viper.WriteConfigAs(cfgPath); writeErr != nil { 61 | fmt.Println("Error creating default config file:", writeErr) 62 | } else { 63 | fmt.Printf("Created default config at %s\n", cfgPath) 64 | } 65 | _ = viper.ReadInConfig() 66 | } else { 67 | fmt.Println("Error reading config file:", err) 68 | } 69 | } 70 | 71 | if err := viper.Unmarshal(&cfg); err != nil { 72 | fmt.Println("Error unmarshalling config:", err) 73 | os.Exit(1) 74 | } 75 | 76 | // Initialize prompt configuration 77 | InitPromptConfig() 78 | } 79 | 80 | func GetProvider() string { 81 | if cfg == nil { 82 | InitConfig() 83 | } 84 | return cfg.ActiveProvider 85 | } 86 | 87 | func GetActiveProviderConfig() (*ProviderConfig, error) { 88 | if cfg == nil { 89 | InitConfig() 90 | } 91 | providerName := cfg.ActiveProvider 92 | providerConfig, ok := cfg.Providers[providerName] 93 | if !ok { 94 | return nil, fmt.Errorf("provider '%s' not configured", providerName) 95 | } 96 | return &providerConfig, nil 97 | } 98 | 99 | func GetAPIKey() (string, error) { 100 | if cfg == nil { 101 | InitConfig() 102 | } 103 | 104 | providerConfig, err := GetActiveProviderConfig() 105 | if err != nil { 106 | return "", err 107 | } 108 | 109 | if providerConfig.APIKey == "" { 110 | return "", fmt.Errorf("API key for provider '%s' is not set", cfg.ActiveProvider) 111 | } 112 | 113 | apiKey := providerConfig.APIKey 114 | 115 | // Check if the API key is an environment variable reference 116 | if strings.HasPrefix(apiKey, "$") { 117 | envVarName := strings.TrimPrefix(apiKey, "$") 118 | envValue := os.Getenv(envVarName) 119 | if envValue == "" { 120 | return "", fmt.Errorf("environment variable '%s' for provider '%s' is not set or empty", envVarName, cfg.ActiveProvider) 121 | } 122 | return envValue, nil 123 | } 124 | 125 | return apiKey, nil 126 | } 127 | 128 | func GetModel() (string, error) { 129 | providerConfig, err := GetActiveProviderConfig() 130 | if err != nil { 131 | return "", err 132 | } 133 | if providerConfig.Model == "" { 134 | return "", fmt.Errorf("model for provider '%s' is not set", cfg.ActiveProvider) 135 | } 136 | return providerConfig.Model, nil 137 | } 138 | 139 | func GetEndpoint() (string, error) { 140 | providerConfig, err := GetActiveProviderConfig() 141 | if err != nil { 142 | return "", err 143 | } 144 | 145 | // If custom endpoint is configured, use it 146 | if providerConfig.EndpointURL != "" { 147 | return providerConfig.EndpointURL, nil 148 | } 149 | 150 | // Return default endpoints based on provider 151 | switch cfg.ActiveProvider { 152 | case "openai": 153 | return "https://api.openai.com/v1", nil 154 | case "copilot": 155 | return "https://api.githubcopilot.com", nil 156 | case "anthropic": 157 | return "", nil // Anthropic uses CLI, no endpoint needed 158 | default: 159 | return "", fmt.Errorf("no default endpoint available for provider '%s'", cfg.ActiveProvider) 160 | } 161 | } 162 | 163 | func SetProvider(provider string) error { 164 | if cfg == nil { 165 | InitConfig() 166 | } 167 | cfg.ActiveProvider = provider 168 | viper.Set("active_provider", provider) 169 | return viper.WriteConfig() 170 | } 171 | 172 | func SetModel(model string) error { 173 | if cfg == nil { 174 | InitConfig() 175 | } 176 | provider := cfg.ActiveProvider 177 | viper.Set(fmt.Sprintf("providers.%s.model", provider), model) 178 | return viper.WriteConfig() 179 | } 180 | 181 | func SetAPIKey(provider, apiKey string) error { 182 | if cfg == nil { 183 | InitConfig() 184 | } 185 | viper.Set(fmt.Sprintf("providers.%s.api_key", provider), apiKey) 186 | return viper.WriteConfig() 187 | } 188 | 189 | func validateEndpointURL(endpoint string) error { 190 | if endpoint == "" { 191 | return nil // Empty endpoint is valid (will use default) 192 | } 193 | 194 | parsedURL, err := url.Parse(endpoint) 195 | if err != nil { 196 | return fmt.Errorf("invalid URL format: %w", err) 197 | } 198 | 199 | if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { 200 | return fmt.Errorf("endpoint must use http or https protocol") 201 | } 202 | 203 | if parsedURL.Host == "" { 204 | return fmt.Errorf("endpoint must have a valid host") 205 | } 206 | 207 | return nil 208 | } 209 | 210 | func SetEndpoint(provider, endpoint string) error { 211 | if cfg == nil { 212 | InitConfig() 213 | } 214 | 215 | // Validate endpoint URL 216 | if err := validateEndpointURL(endpoint); err != nil { 217 | return err 218 | } 219 | 220 | viper.Set(fmt.Sprintf("providers.%s.endpoint_url", provider), endpoint) 221 | return viper.WriteConfig() 222 | } 223 | 224 | func LoadGitHubToken() (string, error) { 225 | tok, err := tryGetTokenFromGHCLI() 226 | if err == nil && tok != "" { 227 | return tok, nil 228 | } 229 | 230 | configDir := getConfigDir() 231 | 232 | filePaths := []string{ 233 | filepath.Join(configDir, "github-copilot", "hosts.json"), 234 | filepath.Join(configDir, "github-copilot", "apps.json"), 235 | } 236 | 237 | for _, filePath := range filePaths { 238 | data, err := os.ReadFile(filePath) 239 | if err != nil { 240 | continue 241 | } 242 | 243 | var configData map[string]map[string]interface{} 244 | if err := json.Unmarshal(data, &configData); err != nil { 245 | continue 246 | } 247 | 248 | for key, value := range configData { 249 | if strings.Contains(key, "github.com") { 250 | if oauthToken, ok := value["oauth_token"].(string); ok && oauthToken != "" { 251 | return oauthToken, nil 252 | } 253 | } 254 | } 255 | } 256 | 257 | return "", fmt.Errorf("GitHub token not found via 'gh auth token'; run 'gh auth login' to authenticate the GitHub CLI") 258 | } 259 | func tryGetTokenFromGHCLI() (string, error) { 260 | out, err := exec.Command("gh", "auth", "token").Output() 261 | if err != nil { 262 | return "", err 263 | } 264 | tok := strings.TrimSpace(string(out)) 265 | if tok == "" { 266 | return "", fmt.Errorf("gh returned empty token") 267 | } 268 | return tok, nil 269 | } 270 | 271 | func GetNumSuggestions() int { 272 | if cfg == nil { 273 | InitConfig() 274 | } 275 | providerConfig, err := GetActiveProviderConfig() 276 | if err != nil { 277 | return 10 // Default to 10 if error 278 | } 279 | if providerConfig.NumSuggestions <= 0 { 280 | return 10 // Default to 10 if not set or invalid 281 | } 282 | return providerConfig.NumSuggestions 283 | } 284 | 285 | func SetNumSuggestions(provider, numSuggestions string) error { 286 | if cfg == nil { 287 | InitConfig() 288 | } 289 | viper.Set(fmt.Sprintf("providers.%s.num_suggestions", provider), numSuggestions) 290 | return viper.WriteConfig() 291 | } 292 | 293 | func GetLanguage() string { 294 | if cfg == nil { 295 | InitConfig() 296 | } 297 | if cfg.Language == "" { 298 | return "en" // Default to English 299 | } 300 | return cfg.Language 301 | } 302 | 303 | func SetLanguage(language string) error { 304 | if cfg == nil { 305 | InitConfig() 306 | } 307 | cfg.Language = language 308 | viper.Set("language", language) 309 | return viper.WriteConfig() 310 | } 311 | 312 | func getConfigDir() string { 313 | if xdgConfig := os.Getenv("XDG_CONFIG_HOME"); xdgConfig != "" { 314 | return xdgConfig 315 | } else if runtime.GOOS == "windows" { 316 | homeDir, err := os.UserHomeDir() 317 | if err != nil { 318 | fmt.Println("Error getting user home directory:", err) 319 | os.Exit(1) 320 | } 321 | return filepath.Join(homeDir, "AppData", "Local") 322 | } else { 323 | return filepath.Join(os.Getenv("HOME"), ".config") 324 | } 325 | } 326 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # lazycommit 2 | 3 | AI-powered Git commit message generator that analyzes your staged changes and outputs conventional commit messages. 4 | 5 | 8 | 9 | 10 | ## Features 11 | 12 | - Generates configurable number of commit message suggestions from your staged diff 13 | - Generates 10 pull request titles based on the diff between the current branch and a target branch 14 | - Providers: GitHub Copilot (default), OpenAI, Anthropic (Claude Code CLI) 15 | - Multi-language support: English and Spanish 16 | - Interactive config to pick provider/model/language and set keys 17 | - Simple output suitable for piping into TUI menus (one message per line) 18 | 19 | ## Installation 20 | 21 | ```bash 22 | go install github.com/m7medvision/lazycommit@latest 23 | ``` 24 | 25 | Or build from source: 26 | 27 | ```bash 28 | git clone https://github.com/m7medvision/lazycommit.git 29 | cd lazycommit 30 | go build -o lazycommit main.go 31 | ``` 32 | 33 | ## CLI 34 | 35 | - Root command: `lazycommit` 36 | - Subcommands: 37 | - `lazycommit commit` — prints 10 suggested commit messages to stdout, one per line, based on `git diff --cached`. 38 | - `lazycommit pr ` — prints 10 suggested pull request titles to stdout, one per line, based on diff between current branch and ``. 39 | - `lazycommit config get` — prints the active provider, model and language. 40 | - `lazycommit config set` — interactive setup for provider, API key, model, and language. 41 | 42 | Exit behaviors: 43 | - If no staged changes: prints "No staged changes to commit." and exits 0. 44 | - On config/LLM errors: prints to stderr and exits non‑zero. 45 | 46 | ### Examples 47 | 48 | Generate suggestions after staging changes: 49 | 50 | ```bash 51 | git add . 52 | lazycommit commit 53 | ``` 54 | 55 | Pipe the first suggestion to commit (bash example): 56 | 57 | ```bash 58 | MSG=$(lazycommit commit | sed -n '1p') 59 | [ -n "$MSG" ] && git commit -m "$MSG" 60 | ``` 61 | 62 | Pick interactively with `fzf`: 63 | 64 | ```bash 65 | git add . 66 | lazycommit commit | fzf --prompt='Pick commit> ' | xargs -r -I {} git commit -m "{}" 67 | ``` 68 | 69 | Generate PR titles against `main` branch: 70 | 71 | ```bash 72 | lazycommit pr main 73 | ``` 74 | 75 | ## Configuration 76 | 77 | lazycommit uses a two-file configuration system to separate sensitive provider settings from shareable prompt configurations: 78 | 79 | ### 1. Provider Configuration (`~/.config/.lazycommit.yaml`) 80 | Contains API keys, tokens, and provider-specific settings. **Do not share this file.** 81 | 82 | ```yaml 83 | active_provider: copilot # default if a GitHub token is found 84 | language: en # commit message language: "en" (English) or "es" (Spanish) 85 | providers: 86 | copilot: 87 | api_key: "$GITHUB_TOKEN" # Uses GitHub token; token is exchanged internally 88 | model: "gpt-4o" # or "openai/gpt-4o"; both accepted 89 | # endpoint_url: "https://api.githubcopilot.com" # Optional - uses default if not specified 90 | openai: 91 | api_key: "$OPENAI_API_KEY" 92 | model: "gpt-4o" 93 | # endpoint_url: "https://api.openai.com/v1" # Optional - uses default if not specified 94 | anthropic: 95 | model: "claude-haiku-4-5" # Uses Claude Code CLI - no API key needed 96 | num_suggestions: 10 # Number of commit suggestions to generate 97 | # Custom provider example (e.g., local Ollama): 98 | # local: 99 | # api_key: "not-needed" 100 | # model: "llama3.1:8b" 101 | # endpoint_url: "http://localhost:11434/v1" 102 | ``` 103 | 104 | #### Anthropic Provider (Claude Code CLI) 105 | 106 | The Anthropic provider integrates with your local [Claude Code CLI](https://github.com/anthropics/claude-code) installation: 107 | 108 | - **No API key required**: Uses your existing Claude Code authentication 109 | - **Fast and cost-effective**: Leverages Claude Haiku model 110 | - **Configurable**: Set custom number of suggestions per provider 111 | 112 | Requirements: 113 | - Claude Code CLI installed and authenticated 114 | - Command `claude` available in PATH 115 | 116 | ### 2. Prompt Configuration (`~/.config/.lazycommit.prompts.yaml`) 117 | Contains prompt templates and message configurations. **Safe to share in dotfiles and Git.** 118 | 119 | This file is automatically created on first run with sensible defaults: 120 | 121 | ```yaml 122 | system_message: "You are a helpful assistant that generates git commit messages, and pull request titles." 123 | commit_message_template: "Based on the following git diff, generate 10 conventional commit messages. Each message should be on a new line, without any numbering or bullet points:\n\n%s" 124 | pr_title_template: "Based on the following git diff, generate 10 pull request title suggestions. Each title should be on a new line, without any numbering or bullet points:\n\n%s" 125 | ``` 126 | 127 | 128 | ### Custom Endpoints 129 | 130 | You can configure custom API endpoints for any provider, which is useful for: 131 | - **Local AI models**: Ollama, LM Studio, or other local inference servers 132 | - **Enterprise proxies**: Internal API gateways or proxy servers 133 | - **Alternative providers**: Any OpenAI-compatible API endpoint 134 | 135 | The `endpoint_url` field is optional. If not specified, the official endpoint for that provider will be used. 136 | 137 | #### Examples 138 | 139 | **Ollama (local):** 140 | ```yaml 141 | active_provider: openai # Use openai provider for Ollama compatibility 142 | providers: 143 | openai: 144 | api_key: "ollama" # Ollama doesn't require real API keys 145 | model: "llama3.1:8b" 146 | endpoint_url: "http://localhost:11434/v1" 147 | ``` 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | ### Language Configuration 160 | 161 | lazycommit supports generating commit messages in different languages. Set the `language` field in your config: 162 | 163 | ```yaml 164 | language: es # Spanish 165 | # or 166 | language: en # English (default) 167 | ``` 168 | 169 | You can also configure it interactively: 170 | 171 | ```bash 172 | lazycommit config set # Select language in the interactive menu 173 | ``` 174 | 175 | The language setting automatically instructs the AI to generate commit messages in the specified language, regardless of the provider used. 176 | 177 | **Supported languages:** 178 | - `en` - English (default) 179 | - `es` - Spanish (Español) 180 | 181 | ## Integration with TUI Git clients 182 | 183 | Because `lazycommit commit` prints plain lines, it plugs nicely into menu UIs. 184 | 185 | ### Lazygit custom command 186 | 187 | Add this to `~/.config/lazygit/config.yml`: 188 | 189 | ```yaml 190 | customCommands: 191 | - key: "" # ctrl + a 192 | description: "pick AI commit" 193 | command: 'git commit -m "{{.Form.Msg}}"' 194 | context: "files" 195 | prompts: 196 | - type: "menuFromCommand" 197 | title: "ai Commits" 198 | key: "Msg" 199 | command: "lazycommit commit" 200 | filter: '^(?P.+)$' 201 | valueFormat: "{{ .raw }}" 202 | labelFormat: "{{ .raw | green }}" 203 | ``` 204 | 205 | This config will allows you to edit the commit message after picking from lazycommit suggestions. 206 | ```yaml 207 | - key: "" # ctrl + b 208 | description: "Pick AI commit (edit before committing)" 209 | context: "files" 210 | command: > 211 | bash -c 'msg="{{.Form.Msg}}"; echo "$msg" > .git/COMMIT_EDITMSG && ${EDITOR:-nvim} .git/COMMIT_EDITMSG && if [ -s .git/COMMIT_EDITMSG ]; then 212 | 213 | git commit -F .git/COMMIT_EDITMSG; 214 | else 215 | 216 | echo "Commit message is empty, commit aborted."; 217 | fi' 218 | 219 | prompts: 220 | - type: "menuFromCommand" 221 | title: "ai Commits" 222 | key: "Msg" 223 | command: "lazycommit commit" 224 | filter: '^(?P.+)$' 225 | valueFormat: "{{ .raw }}" 226 | labelFormat: "{{ .raw | green }}" 227 | output: terminal 228 | ``` 229 | 230 | 231 | 232 | ### Commitizen 233 | 234 | First, install the Commitizen plugin: 235 | 236 | ```bash 237 | pip install cz-lazycommit 238 | # or if you are using Arch Linux: 239 | uv tool install commitizen --with cz-lazycommit 240 | ``` 241 | 242 | Then use the plugin with the following command: 243 | 244 | ```bash 245 | git cz --name cz_lazycommit commit 246 | ``` 247 | 248 | If you are using Commitizen with Lazygit, you can add this custom command: 249 | 250 | ```yaml 251 | - key: "C" 252 | command: "git cz --name cz_lazycommit commit" 253 | description: "Commit with Commitizen" 254 | context: "files" 255 | loadingText: "Opening Commitizen commit tool" 256 | output: terminal 257 | ``` 258 | 259 | 260 | ## Troubleshooting 261 | 262 | - "No staged changes to commit." — run `git add` first. 263 | - "API key not set" — set the appropriate key in `.lazycommit.yaml` or env var and rerun. 264 | - Copilot errors about token exchange — ensure your GitHub token has models scope or is valid; try setting `GITHUB_TOKEN`. 265 | 266 | ## License 267 | 268 | MIT 269 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= 2 | github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= 3 | github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s= 4 | github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= 5 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 6 | github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI= 7 | github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= 8 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 9 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 10 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 11 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 12 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 13 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 14 | github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= 15 | github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 16 | github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= 17 | github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= 18 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 19 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 20 | github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog= 21 | github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= 22 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 23 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 24 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= 25 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= 26 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 27 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 28 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 29 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 30 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 31 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 32 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 33 | github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU= 34 | github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= 35 | github.com/mattn/go-isatty v0.0.8 h1:HLtExJ+uU2HOZ+wI0Tt5DtUDrx8yhUqDcp7fYERX4CE= 36 | github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= 37 | github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4= 38 | github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= 39 | github.com/openai/openai-go v1.11.1 h1:fTQ4Sr9eoRiWFAoHzXiZZpVi6KtLeoTMyGrcOCudjNU= 40 | github.com/openai/openai-go v1.11.1/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y= 41 | github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= 42 | github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= 43 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 44 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 45 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 46 | github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= 47 | github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= 48 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 49 | github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= 50 | github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= 51 | github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= 52 | github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= 53 | github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs= 54 | github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4= 55 | github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= 56 | github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= 57 | github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= 58 | github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= 59 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 60 | github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M= 61 | github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 62 | github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= 63 | github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= 64 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 65 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 66 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 67 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 68 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 69 | github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= 70 | github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= 71 | github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= 72 | github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= 73 | github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= 74 | github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= 75 | github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= 76 | github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= 77 | github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= 78 | github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= 79 | github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= 80 | github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= 81 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 82 | go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= 83 | go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= 84 | go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= 85 | go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= 86 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 87 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 88 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 89 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 90 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 91 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 92 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 93 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 94 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 95 | golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 96 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 97 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 98 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 99 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 100 | golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= 101 | golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 102 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 103 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 104 | golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg= 105 | golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0= 106 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 107 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 108 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 109 | golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 110 | golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= 111 | golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= 112 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 113 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 114 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 115 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 116 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 117 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 118 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 119 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 120 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 121 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 122 | --------------------------------------------------------------------------------