diff --git a/.gitignore b/.gitignore index 99b8a86..77e4d5b 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,4 @@ test.txt AGENTS.md commit~ commit-msg +.idea \ No newline at end of file diff --git a/cmd/cli/createMsg.go b/cmd/cli/createMsg.go index 3de6a81..e04dfc5 100644 --- a/cmd/cli/createMsg.go +++ b/cmd/cli/createMsg.go @@ -146,7 +146,7 @@ func CreateCommitMsg(Store *store.StoreMethods, dryRun bool, autoCommit bool, ve // Handle dry-run mode: display what would be sent to LLM without making API call if dryRun { pterm.Println() - displayDryRunInfo(commitLLM, config, changes, apiKey, verbose) + displayDryRunInfo(commitLLM, Store, config, changes, apiKey, verbose) return } @@ -355,32 +355,32 @@ func generateMessage(ctx context.Context, provider llm.Provider, changes string, // generateMessageWithCache generates a commit message with caching support. func generateMessageWithCache(ctx context.Context, provider llm.Provider, store *store.StoreMethods, providerType types.LLMProvider, changes string, opts *types.GenerationOptions) (string, error) { startTime := time.Now() - + // Determine if this is a first attempt (cache check eligible) isFirstAttempt := opts == nil || opts.Attempt <= 1 - + // Check cache first (only for first attempt to avoid caching regenerations) if isFirstAttempt { if cachedEntry, found := store.GetCachedMessage(providerType, changes, opts); found { pterm.Info.Printf("Using cached commit message (saved $%.4f)\n", cachedEntry.Cost) - + // Record cache hit event event := &types.GenerationEvent{ Provider: providerType, Success: true, GenerationTime: float64(time.Since(startTime).Nanoseconds()) / 1e6, // Convert to milliseconds - TokensUsed: 0, // No tokens used for cached result - Cost: 0, // No cost for cached result + TokensUsed: 0, // No tokens used for cached result + Cost: 0, // No cost for cached result CacheHit: true, CacheChecked: true, Timestamp: time.Now().UTC().Format(time.RFC3339), } - + if err := store.RecordGenerationEvent(event); err != nil { // Log the error but don't fail the operation fmt.Printf("Warning: Failed to record usage statistics: %v\n", err) } - + return cachedEntry.Message, nil } } @@ -388,12 +388,14 @@ func generateMessageWithCache(ctx context.Context, provider llm.Provider, store // Generate new message message, err := provider.Generate(ctx, changes, opts) generationTime := float64(time.Since(startTime).Nanoseconds()) / 1e6 // Convert to milliseconds - + + customTemplate, _ := store.GetTemplate() + // Estimate tokens and cost - inputTokens := estimateTokens(types.BuildCommitPrompt(changes, opts)) + inputTokens := estimateTokens(types.BuildCommitPromptWithTemplate(changes, opts, customTemplate)) outputTokens := 100 // Estimate output tokens cost := estimateCost(providerType, inputTokens, outputTokens) - + // Record generation event event := &types.GenerationEvent{ Provider: providerType, @@ -405,17 +407,17 @@ func generateMessageWithCache(ctx context.Context, provider llm.Provider, store CacheChecked: isFirstAttempt, // Only first attempts check cache Timestamp: time.Now().UTC().Format(time.RFC3339), } - + if err != nil { event.ErrorMessage = err.Error() } - + // Record the event regardless of success/failure if statsErr := store.RecordGenerationEvent(event); statsErr != nil { // Log the error but don't fail the operation fmt.Printf("Warning: Failed to record usage statistics: %v\n", statsErr) } - + if err != nil { return "", err } @@ -630,7 +632,7 @@ func displayMissingCredentialHint(provider types.LLMProvider) { } // displayDryRunInfo shows what would be sent to the LLM without making an API call -func displayDryRunInfo(provider types.LLMProvider, config *types.Config, changes string, apiKey string, verbose bool) { +func displayDryRunInfo(provider types.LLMProvider, storeMethods *store.StoreMethods, config *types.Config, changes string, apiKey string, verbose bool) { pterm.DefaultHeader.WithFullWidth(). WithBackgroundStyle(pterm.NewStyle(pterm.BgBlue)). WithTextStyle(pterm.NewStyle(pterm.FgWhite, pterm.Bold)). @@ -665,7 +667,8 @@ func displayDryRunInfo(provider types.LLMProvider, config *types.Config, changes // Build and display the prompt opts := &types.GenerationOptions{Attempt: 1} - prompt := types.BuildCommitPrompt(changes, opts) + customTemplate, _ := storeMethods.GetTemplate() + prompt := types.BuildCommitPromptWithTemplate(changes, opts, customTemplate) pterm.DefaultSection.Println("Prompt That Would Be Sent") pterm.Println() diff --git a/cmd/cli/store/store.go b/cmd/cli/store/store.go index cda2acf..21cc152 100644 --- a/cmd/cli/store/store.go +++ b/cmd/cli/store/store.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "time" "os" @@ -62,8 +63,10 @@ type LLMProvider struct { // Config describes the on-disk structure for all saved LLM providers. type Config struct { - Default types.LLMProvider `json:"default"` - LLMProviders []types.LLMProvider `json:"models"` + Default types.LLMProvider `json:"default"` + LLMProviders []types.LLMProvider `json:"models"` + CustomTemplate string `json:"custom_template,omitempty"` + TemplateUpdatedAt string `json:"template_updated_at,omitempty"` } // Save persists or updates an LLM provider entry, marking it as the default. @@ -459,3 +462,122 @@ func (s *StoreMethods) GetProviderRanking() []types.LLMProvider { func (s *StoreMethods) ResetUsageStats() error { return s.usage.ResetStats() } + +// Template management methods + +// SaveTemplate stores a custom commit message template. +func (s *StoreMethods) SaveTemplate(template string) error { + var cfg Config + + configPath, err := StoreUtils.GetConfigPath() + + if err != nil { + return err + } + + isConfigExists := StoreUtils.CheckConfig(configPath) + if !isConfigExists { + err := StoreUtils.CreateConfigFile(configPath) + if err != nil { + return err + } + } + + data, err := os.ReadFile(configPath) + if errors.Is(err, os.ErrNotExist) { + data = []byte("{}") + } else if err != nil { + return err + } + + if len(data) > 2 { + err = json.Unmarshal(data, &cfg) + if err != nil { + return fmt.Errorf("config file format error: %w", err) + } + } + + cfg.CustomTemplate = template + cfg.TemplateUpdatedAt = fmt.Sprintf("%v", time.Now()) + + data, err = json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + + return os.WriteFile(configPath, data, 0600) +} + +func (s *StoreMethods) GetTemplate() (string, error) { + var cfg Config + + configPath, err := StoreUtils.GetConfigPath() + if err != nil { + return "", err + } + + isConfigExists := StoreUtils.CheckConfig(configPath) + if !isConfigExists { + return "", fmt.Errorf("config file does not exist at %s, run 'commit llm setup' to create it", configPath) + } + + data, err := os.ReadFile(configPath) + if err != nil { + return "", err + } + + if len(data) > 2 { + err = json.Unmarshal(data, &cfg) + if err != nil { + return "", fmt.Errorf("config file format error: %w", err) + } + } + + if cfg.CustomTemplate == "" { + return "", errors.New("no custom template set") + } + + return cfg.CustomTemplate, nil +} + +// DeleteTemplate removes the custom commit message template. +func (s *StoreMethods) DeleteTemplate() error { + var cfg Config + + configPath, err := StoreUtils.GetConfigPath() + if err != nil { + return err + } + + isConfigExists := StoreUtils.CheckConfig(configPath) + if !isConfigExists { + return fmt.Errorf("config file does not exist at %s, run 'commit llm setup' to create it", configPath) + } + + data, err := os.ReadFile(configPath) + if err != nil { + return err + } + + if len(data) > 2 { + err = json.Unmarshal(data, &cfg) + if err != nil { + return fmt.Errorf("config file format error: %w", err) + } + } + + cfg.CustomTemplate = "" + cfg.TemplateUpdatedAt = "" + + data, err = json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + + return os.WriteFile(configPath, data, 0600) +} + +func (s *StoreMethods) HasCustomTemplate() bool { + template, err := s.GetTemplate() + return err == nil && template != "" +} diff --git a/cmd/cli/template.go b/cmd/cli/template.go new file mode 100644 index 0000000..e96c247 --- /dev/null +++ b/cmd/cli/template.go @@ -0,0 +1,171 @@ +package cmd + +import ( + "bufio" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" +) + +var ( + setTemplate bool + getTemplate bool + deleteTemplate bool +) + +var templateCmd = &cobra.Command{ + Use: "template", + Short: "Manage commit message templates", + Long: `Manage the commit message templates for LLM-generated commits. + +You can set, view, or delete custom templates that will be used by the LLM +to generate commit messages in your preferred format. + +Examples: + commit llm template --set # set a new custom template + commit llm template --get # view the current template + commit llm template --delete # delete the custom template`, + Run: func(cmd *cobra.Command, args []string) { + if setTemplate { + handleSetTemplate() + } else if getTemplate { + handleGetTemplate() + } else if deleteTemplate { + handleDeleteTemplate() + } else { + cmd.Help() + } + }, +} + +func init() { + llmCmd.AddCommand(templateCmd) + templateCmd.Flags().BoolVar(&setTemplate, "set", false, "Set a custom commit message template") + templateCmd.Flags().BoolVar(&getTemplate, "get", false, "Get the current custom template") + templateCmd.Flags().BoolVar(&deleteTemplate, "delete", false, "Delete the custom template") +} + +func handleSetTemplate() { + fmt.Println("Custom Commit Message Template Setup") + fmt.Println("========================================") + fmt.Println() + fmt.Println("Enter your custom commit message template.") + fmt.Println("You can use placeholders that the LLM will fill in:") + fmt.Println(" - Use natural language to describe your desired format") + fmt.Println(" - Example: 'Type: Brief description\\n\\nDetailed explanation\\n\\nRelated: #issue'") + fmt.Println() + fmt.Println("Enter your template (press Ctrl+D or Ctrl+Z when done):") + fmt.Println("----------------------------------------") + + // Read multiline input + scanner := bufio.NewScanner(os.Stdin) + var templateLines []string + + for scanner.Scan() { + templateLines = append(templateLines, scanner.Text()) + } + + if err := scanner.Err(); err != nil { + fmt.Printf("x Error reading template: %v\n", err) + return + } + + template := strings.Join(templateLines, "\n") + template = strings.TrimSpace(template) + + if template == "" { + fmt.Println("x Template cannot be empty.") + return + } + + // save template to store + err := Store.SaveTemplate(template) + if err != nil { + fmt.Printf("x Error saving template: %v\n", err) + return + } + + fmt.Println() + fmt.Println("Custom template saved successfully!") + fmt.Println() + fmt.Println("Your template:") + fmt.Println("----------------------------------------") + fmt.Println(template) + fmt.Println("----------------------------------------") + fmt.Println() + fmt.Println("💡 This template will now be used for all commit message generation.") + fmt.Println(" Run 'commit llm template --get' to view it anytime.") + fmt.Println(" Run 'commit llm template --delete' to remove it.") + +} + +func handleGetTemplate() { + template, err := Store.GetTemplate() + if err != nil { + if err.Error() == "no custom template set" { + fmt.Println("No custom template is currently set.") + fmt.Println() + fmt.Println("To set a custom template, run: commit llm template --set") + fmt.Println(" The default template will be used for commit message generation.") + return + } + fmt.Printf("Error retrieving template: %v\n", err) + return + } + + fmt.Println("📋 Current Custom Template") + fmt.Println("========================================") + fmt.Println() + fmt.Println(template) + fmt.Println() + fmt.Println("========================================") + fmt.Println() + fmt.Println("💡 To update this template, run: commit llm template --set") + fmt.Println(" To delete this template, run: commit llm template --delete") +} + +func handleDeleteTemplate() { + _, err := Store.GetTemplate() + if err != nil { + if err.Error() == "no custom template set" { + fmt.Println("No custom template is currently set.") + fmt.Println() + fmt.Println("To set a custom template, run: commit llm template --set") + fmt.Println(" The default template will be used for commit message generation.") + return + } + fmt.Printf("Error retrieving template: %v\n", err) + return + } + + // Confirm deletion + fmt.Println("Are you sure you want to delete the custom template?") + fmt.Println(" The default template will be used after deletion.") + fmt.Print(" Type 'yes' to confirm: ") + + reader := bufio.NewReader(os.Stdin) + response, err := reader.ReadString('\n') + if err != nil { + fmt.Printf("❌ Error reading confirmation: %v\n", err) + return + } + + response = strings.TrimSpace(strings.ToLower(response)) + if response != "yes" { + fmt.Println("❌ Template deletion cancelled.") + return + } + + // Delete the template + err = Store.DeleteTemplate() + if err != nil { + fmt.Printf("❌ Error deleting template: %v\n", err) + return + } + + fmt.Println() + fmt.Println("Custom template deleted successfully!") + fmt.Println(" The default template will now be used for commit message generation.") +} diff --git a/internal/chatgpt/chatgpt.go b/internal/chatgpt/chatgpt.go index 445400f..07d25cc 100644 --- a/internal/chatgpt/chatgpt.go +++ b/internal/chatgpt/chatgpt.go @@ -4,12 +4,15 @@ import ( "context" "fmt" + "github.com/dfanso/commit-msg/cmd/cli/store" openai "github.com/openai/openai-go/v3" "github.com/openai/openai-go/v3/option" "github.com/dfanso/commit-msg/pkg/types" ) +var storeMethods *store.StoreMethods + const ( chatgptModel = openai.ChatModelGPT4o ) @@ -20,7 +23,9 @@ func GenerateCommitMessage(config *types.Config, changes string, apiKey string, client := openai.NewClient(option.WithAPIKey(apiKey)) - prompt := types.BuildCommitPrompt(changes, opts) + // getting the custom template + customTemplate, _ := storeMethods.GetTemplate() + prompt := types.BuildCommitPromptWithTemplate(changes, opts, customTemplate) resp, err := client.Chat.Completions.New(context.TODO(), openai.ChatCompletionNewParams{ Messages: []openai.ChatCompletionMessageParamUnion{ diff --git a/internal/claude/claude.go b/internal/claude/claude.go index c93a00a..61b6477 100644 --- a/internal/claude/claude.go +++ b/internal/claude/claude.go @@ -7,18 +7,19 @@ import ( "fmt" "net/http" + "github.com/dfanso/commit-msg/cmd/cli/store" httpClient "github.com/dfanso/commit-msg/internal/http" "github.com/dfanso/commit-msg/pkg/types" ) const ( - claudeModel = "claude-3-5-sonnet-20241022" - claudeMaxTokens = 200 - claudeAPIEndpoint = "https://api.anthropic.com/v1/messages" - claudeAPIVersion = "2023-06-01" - contentTypeJSON = "application/json" + claudeModel = "claude-3-5-sonnet-20241022" + claudeMaxTokens = 200 + claudeAPIEndpoint = "https://api.anthropic.com/v1/messages" + claudeAPIVersion = "2023-06-01" + contentTypeJSON = "application/json" anthropicVersionHeader = "anthropic-version" - xAPIKeyHeader = "x-api-key" + xAPIKeyHeader = "x-api-key" ) // ClaudeRequest describes the payload sent to Anthropic's Claude messages API. @@ -38,10 +39,13 @@ type ClaudeResponse struct { } `json:"content"` } +var storeMethods *store.StoreMethods + // GenerateCommitMessage produces a commit summary using Anthropic's Claude API. func GenerateCommitMessage(config *types.Config, changes string, apiKey string, opts *types.GenerationOptions) (string, error) { - prompt := types.BuildCommitPrompt(changes, opts) + customTemplate, _ := storeMethods.GetTemplate() + prompt := types.BuildCommitPromptWithTemplate(changes, opts, customTemplate) reqBody := ClaudeRequest{ Model: claudeModel, diff --git a/internal/gemini/gemini.go b/internal/gemini/gemini.go index 3d8146d..c187dcc 100644 --- a/internal/gemini/gemini.go +++ b/internal/gemini/gemini.go @@ -4,6 +4,7 @@ import ( "context" "fmt" + "github.com/dfanso/commit-msg/cmd/cli/store" "github.com/google/generative-ai-go/genai" "google.golang.org/api/option" @@ -15,11 +16,14 @@ const ( geminiTemperature = 0.2 ) +var storeMethods *store.StoreMethods + // GenerateCommitMessage asks Google Gemini to author a commit message for the // supplied repository changes and optional style instructions. func GenerateCommitMessage(config *types.Config, changes string, apiKey string, opts *types.GenerationOptions) (string, error) { // Prepare request to Gemini API - prompt := types.BuildCommitPrompt(changes, opts) + customTemplate, _ := storeMethods.GetTemplate() + prompt := types.BuildCommitPromptWithTemplate(changes, opts, customTemplate) // Create context and client ctx := context.Background() diff --git a/internal/grok/grok.go b/internal/grok/grok.go index d86e784..2370322 100644 --- a/internal/grok/grok.go +++ b/internal/grok/grok.go @@ -7,23 +7,27 @@ import ( "io" "net/http" + "github.com/dfanso/commit-msg/cmd/cli/store" httpClient "github.com/dfanso/commit-msg/internal/http" "github.com/dfanso/commit-msg/pkg/types" ) const ( - grokModel = "grok-3-mini-fast-beta" - grokTemperature = 0 - grokAPIEndpoint = "https://api.x.ai/v1/chat/completions" - grokContentType = "application/json" + grokModel = "grok-3-mini-fast-beta" + grokTemperature = 0 + grokAPIEndpoint = "https://api.x.ai/v1/chat/completions" + grokContentType = "application/json" authorizationPrefix = "Bearer " ) +var storeMethods *store.StoreMethods + // GenerateCommitMessage calls X.AI's Grok API to create a commit message from // the provided Git diff and generation options. func GenerateCommitMessage(config *types.Config, changes string, apiKey string, opts *types.GenerationOptions) (string, error) { // Prepare request to X.AI (Grok) API - prompt := types.BuildCommitPrompt(changes, opts) + customTemplate, _ := storeMethods.GetTemplate() + prompt := types.BuildCommitPromptWithTemplate(changes, opts, customTemplate) request := types.GrokRequest{ diff --git a/internal/groq/groq.go b/internal/groq/groq.go index 4630fb8..dcb2a25 100644 --- a/internal/groq/groq.go +++ b/internal/groq/groq.go @@ -8,6 +8,7 @@ import ( "net/http" "os" + "github.com/dfanso/commit-msg/cmd/cli/store" internalHTTP "github.com/dfanso/commit-msg/internal/http" "github.com/dfanso/commit-msg/pkg/types" ) @@ -48,7 +49,8 @@ var ( // allow overrides in tests baseURL = "https://api.groq.com/openai/v1/chat/completions" // httpClient can be overridden in tests; defaults to the internal http client - httpClient *http.Client + httpClient *http.Client + storeMethods *store.StoreMethods ) func init() { @@ -61,7 +63,8 @@ func GenerateCommitMessage(_ *types.Config, changes string, apiKey string, opts return "", fmt.Errorf("no changes provided for commit message generation") } - prompt := types.BuildCommitPrompt(changes, opts) + customTemplate, _ := storeMethods.GetTemplate() + prompt := types.BuildCommitPromptWithTemplate(changes, opts, customTemplate) model := os.Getenv("GROQ_MODEL") if model == "" { diff --git a/internal/ollama/ollama.go b/internal/ollama/ollama.go index 0db08c9..cb2f172 100644 --- a/internal/ollama/ollama.go +++ b/internal/ollama/ollama.go @@ -7,6 +7,7 @@ import ( "io" "net/http" + "github.com/dfanso/commit-msg/cmd/cli/store" httpClient "github.com/dfanso/commit-msg/internal/http" "github.com/dfanso/commit-msg/pkg/types" ) @@ -29,6 +30,8 @@ type OllamaResponse struct { Done bool `json:"done"` } +var storeMethods *store.StoreMethods + // GenerateCommitMessage uses a locally hosted Ollama model to draft a commit // message from repository changes and optional style guidance. func GenerateCommitMessage(_ *types.Config, changes string, url string, model string, opts *types.GenerationOptions) (string, error) { @@ -37,8 +40,11 @@ func GenerateCommitMessage(_ *types.Config, changes string, url string, model st model = ollamaDefaultModel } + // Checking for the custom template, when building the prompt + customTemplate, _ := storeMethods.GetTemplate() + // Preparing the prompt - prompt := types.BuildCommitPrompt(changes, opts) + prompt := types.BuildCommitPromptWithTemplate(changes, opts, customTemplate) // Generating the request body - add stream: false for non-streaming response reqBody := map[string]interface{}{ diff --git a/pkg/types/prompt.go b/pkg/types/prompt.go index cd55192..02c3283 100644 --- a/pkg/types/prompt.go +++ b/pkg/types/prompt.go @@ -28,8 +28,19 @@ Here are the changes: // BuildCommitPrompt constructs the prompt that will be sent to the LLM, applying // any optional tone/style instructions before appending the repository changes. func BuildCommitPrompt(changes string, opts *GenerationOptions) string { + return BuildCommitPromptWithTemplate(changes, opts, "") +} + +func BuildCommitPromptWithTemplate(changes string, opts *GenerationOptions, customTemplate string) string { var builder strings.Builder - builder.WriteString(CommitPrompt) + + // use custom template if provided + basePrompt := CommitPrompt + if strings.TrimSpace(customTemplate) != "" { + basePrompt = strings.TrimSpace(customTemplate) + "\n\nHere are the changes:\n" + } + + builder.WriteString(basePrompt) if opts != nil { if opts.Attempt > 1 {