mirror of
https://github.com/imjasonh/cnotes
synced 2026-07-08 17:16:00 +00:00
320 lines
8.6 KiB
Go
320 lines
8.6 KiB
Go
package cmd
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/imjasonh/cnotes/internal/config"
|
|
conv "github.com/imjasonh/cnotes/internal/context"
|
|
"github.com/imjasonh/cnotes/internal/notes"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
var (
|
|
debug bool
|
|
rootCmd = &cobra.Command{
|
|
Use: "cnotes",
|
|
Short: "Git notes for Claude conversations",
|
|
Long: `cnotes automatically captures Claude conversation context in git notes.
|
|
|
|
When called by Claude Code hooks, it detects git commit commands and attaches
|
|
conversation context as git notes for easy reference later.`,
|
|
RunE: runHook,
|
|
PersistentPreRun: func(cmd *cobra.Command, args []string) {
|
|
if debug {
|
|
opts := &slog.HandlerOptions{Level: slog.LevelDebug}
|
|
handler := slog.NewTextHandler(os.Stderr, opts)
|
|
slog.SetDefault(slog.New(handler))
|
|
}
|
|
},
|
|
}
|
|
)
|
|
|
|
// HookInput represents the input from Claude Code hooks
|
|
type HookInput struct {
|
|
SessionID string `json:"session_id"`
|
|
TranscriptPath string `json:"transcript_path"`
|
|
CWD string `json:"cwd"`
|
|
HookEventName string `json:"hook_event_name"`
|
|
ToolName string `json:"tool_name,omitempty"`
|
|
ToolInput json.RawMessage `json:"tool_input,omitempty"`
|
|
ToolResponse json.RawMessage `json:"tool_response,omitempty"`
|
|
}
|
|
|
|
// BashToolInput represents bash tool parameters
|
|
type BashToolInput struct {
|
|
Command string `json:"command"`
|
|
Description string `json:"description,omitempty"`
|
|
}
|
|
|
|
// HookOutput represents the response to Claude Code
|
|
type HookOutput struct {
|
|
Decision string `json:"decision,omitempty"`
|
|
}
|
|
|
|
func runHook(cmd *cobra.Command, args []string) error {
|
|
ctx := context.Background()
|
|
|
|
// Check if stdin has data available with a short timeout
|
|
inputBytes, err := readStdinWithTimeout(2 * time.Second)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read input: %w", err)
|
|
}
|
|
|
|
if len(inputBytes) == 0 {
|
|
return fmt.Errorf("no input received - cnotes expects JSON input from Claude Code hooks")
|
|
}
|
|
|
|
// Parse hook input
|
|
var input HookInput
|
|
if err := json.Unmarshal(inputBytes, &input); err != nil {
|
|
return fmt.Errorf("failed to parse input: %w", err)
|
|
}
|
|
|
|
// Only handle PostToolUse events for Bash commands
|
|
if input.HookEventName != "PostToolUse" || input.ToolName != "Bash" {
|
|
// For all other events, just approve
|
|
return writeOutput(HookOutput{Decision: "approve"})
|
|
}
|
|
|
|
// Extract bash command
|
|
var bashInput BashToolInput
|
|
if err := json.Unmarshal(input.ToolInput, &bashInput); err != nil {
|
|
return writeOutput(HookOutput{Decision: "approve"})
|
|
}
|
|
|
|
// Check if this is a git commit command
|
|
if !isGitCommitCommand(bashInput.Command) {
|
|
return writeOutput(HookOutput{Decision: "approve"})
|
|
}
|
|
|
|
// Load configuration
|
|
cfg := config.LoadNotesConfig(input.CWD)
|
|
if !cfg.Enabled {
|
|
return writeOutput(HookOutput{Decision: "approve"})
|
|
}
|
|
|
|
// Process the git commit and attach notes
|
|
if err := processGitCommit(ctx, input, bashInput); err != nil {
|
|
slog.Error("failed to process git commit", "error", err)
|
|
// Don't fail the hook, just log the error
|
|
}
|
|
|
|
return writeOutput(HookOutput{Decision: "approve"})
|
|
}
|
|
|
|
func processGitCommit(ctx context.Context, input HookInput, bashInput BashToolInput) error {
|
|
// Extract git output from tool response
|
|
var gitOutput string
|
|
if len(input.ToolResponse) > 0 {
|
|
var toolResponse struct {
|
|
Stdout string `json:"stdout"`
|
|
}
|
|
if err := json.Unmarshal(input.ToolResponse, &toolResponse); err == nil {
|
|
gitOutput = toolResponse.Stdout
|
|
}
|
|
}
|
|
|
|
if gitOutput == "" {
|
|
return fmt.Errorf("no git output found")
|
|
}
|
|
|
|
// Extract commit hash
|
|
commitHash := extractCommitHash(gitOutput)
|
|
if commitHash == "" {
|
|
return fmt.Errorf("could not extract commit hash")
|
|
}
|
|
|
|
// Create notes manager and load config
|
|
notesManager := notes.NewNotesManager(input.CWD)
|
|
cfg := config.LoadNotesConfig(input.CWD)
|
|
notesManager.SetNotesRef(cfg.NotesRef)
|
|
|
|
// Check if note already exists
|
|
if notesManager.HasConversationNote(ctx, commitHash) {
|
|
return nil
|
|
}
|
|
|
|
// Get the last event time from the previous commit to avoid duplicates
|
|
lastEventTime := getLastEventTimeFromPreviousCommit(ctx, notesManager)
|
|
|
|
// Small delay to ensure transcript is written
|
|
time.Sleep(100 * time.Millisecond)
|
|
|
|
// Extract conversation context since the last event
|
|
contextExtractor := conv.NewContextExtractor(cfg)
|
|
conversationContext, err := contextExtractor.ExtractContextSince(input.TranscriptPath, input.SessionID, lastEventTime)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to extract conversation context: %w", err)
|
|
}
|
|
|
|
// Create conversation excerpt
|
|
excerpt := contextExtractor.CreateExcerpt(conversationContext)
|
|
|
|
// Collect tools used
|
|
toolsUsed := []string{"Bash"}
|
|
for _, interaction := range conversationContext.ToolInteractions {
|
|
if interaction.Tool != "" && !contains(toolsUsed, interaction.Tool) {
|
|
toolsUsed = append(toolsUsed, interaction.Tool)
|
|
}
|
|
}
|
|
|
|
// Create conversation note
|
|
note := notes.ConversationNote{
|
|
SessionID: input.SessionID,
|
|
Timestamp: time.Now(),
|
|
ConversationExcerpt: excerpt,
|
|
ToolsUsed: toolsUsed,
|
|
CommitContext: buildCommitContext(bashInput.Command, gitOutput),
|
|
ClaudeVersion: "claude-sonnet-4-20250514",
|
|
LastEventTime: conversationContext.LastEventTime,
|
|
}
|
|
|
|
// Add the note
|
|
if err := notesManager.AddConversationNote(ctx, commitHash, note); err != nil {
|
|
return fmt.Errorf("failed to add conversation note: %w", err)
|
|
}
|
|
|
|
slog.Info("attached conversation context to commit",
|
|
"commit", commitHash,
|
|
"session_id", input.SessionID)
|
|
|
|
return nil
|
|
}
|
|
|
|
func isGitCommitCommand(command string) bool {
|
|
command = strings.TrimSpace(command)
|
|
patterns := []string{"git commit"}
|
|
for _, pattern := range patterns {
|
|
if strings.Contains(command, pattern) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func extractCommitHash(output string) string {
|
|
lines := strings.Split(output, "\n")
|
|
for _, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
if strings.HasPrefix(line, "[") && strings.Contains(line, "]") {
|
|
parts := strings.Split(line, "]")
|
|
if len(parts) > 0 {
|
|
beforeBracket := strings.TrimSpace(strings.TrimPrefix(parts[0], "["))
|
|
hashParts := strings.Split(beforeBracket, " ")
|
|
if len(hashParts) > 1 {
|
|
return hashParts[1]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func buildCommitContext(command, output string) string {
|
|
var parts []string
|
|
parts = append(parts, fmt.Sprintf("Git command: %s", command))
|
|
|
|
lines := strings.Split(output, "\n")
|
|
for _, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
if strings.Contains(line, "[") && strings.Contains(line, "]") {
|
|
parts = append(parts, fmt.Sprintf("Result: %s", line))
|
|
break
|
|
}
|
|
}
|
|
|
|
return strings.Join(parts, "\n")
|
|
}
|
|
|
|
func contains(slice []string, item string) bool {
|
|
for _, s := range slice {
|
|
if s == item {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// getLastEventTimeFromPreviousCommit gets the last processed event time from the previous commit
|
|
func getLastEventTimeFromPreviousCommit(ctx context.Context, notesManager *notes.NotesManager) time.Time {
|
|
// Try to get the previous commit's note
|
|
note, err := notesManager.GetConversationNote(ctx, "HEAD~1")
|
|
if err != nil || note == nil {
|
|
// No previous commit or note, use a time buffer approach
|
|
cmd := exec.Command("git", "log", "-1", "--format=%cI", "HEAD~1")
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
return time.Time{}
|
|
}
|
|
|
|
timeStr := strings.TrimSpace(string(output))
|
|
commitTime, err := time.Parse(time.RFC3339, timeStr)
|
|
if err != nil {
|
|
return time.Time{}
|
|
}
|
|
|
|
// Use a 60 second buffer for the first commit in a session
|
|
return commitTime.Add(-60 * time.Second)
|
|
}
|
|
|
|
// If we have a previous note with LastEventTime, use it
|
|
if !note.LastEventTime.IsZero() {
|
|
return note.LastEventTime
|
|
}
|
|
|
|
// Fall back to commit time with buffer
|
|
return note.Timestamp.Add(-60 * time.Second)
|
|
}
|
|
|
|
// readStdinWithTimeout reads from stdin with a timeout
|
|
func readStdinWithTimeout(timeout time.Duration) ([]byte, error) {
|
|
type result struct {
|
|
data []byte
|
|
err error
|
|
}
|
|
|
|
ch := make(chan result, 1)
|
|
go func() {
|
|
data, err := io.ReadAll(os.Stdin)
|
|
ch <- result{data, err}
|
|
}()
|
|
|
|
select {
|
|
case res := <-ch:
|
|
return res.data, res.err
|
|
case <-time.After(timeout):
|
|
return nil, fmt.Errorf("timeout waiting for input after %v", timeout)
|
|
}
|
|
}
|
|
|
|
func writeOutput(output HookOutput) error {
|
|
outputBytes, err := json.Marshal(output)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal output: %w", err)
|
|
}
|
|
|
|
fmt.Println(string(outputBytes))
|
|
return nil
|
|
}
|
|
|
|
func Execute() {
|
|
if err := rootCmd.Execute(); err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func init() {
|
|
rootCmd.PersistentFlags().BoolVar(&debug, "debug", false, "Enable debug logging")
|
|
}
|