mirror of
https://github.com/imjasonh/cnotes
synced 2026-07-09 17:39:32 +00:00
BREAKING CHANGE: Complete restructure from hooks to cnotes Major Changes: - Removed all hook handler code except git commit detection - Simplified to focused git notes functionality only - Renamed binary from 'hooks' to 'cnotes' - Restructured CLI: cnotes (hook handler), cnotes show, cnotes install, etc. - Updated module path to github.com/imjasonh/cnotes - Rewrote README and CLAUDE.md for new focused scope New Command Structure: - cnotes # Main hook handler (receives Claude Code calls) - cnotes show # Pretty-print conversation notes - cnotes install # Configure Claude Code to use cnotes - cnotes backup # Backup conversation notes - cnotes restore # Restore notes from backup - cnotes list # List commits with notes This creates a much cleaner, focused tool specifically for git conversation notes.
260 lines
6.8 KiB
Go
260 lines
6.8 KiB
Go
package cmd
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"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()
|
|
|
|
// Read input from stdin (called by Claude Code hooks)
|
|
inputBytes, err := io.ReadAll(os.Stdin)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read input: %w", err)
|
|
}
|
|
|
|
if len(inputBytes) == 0 {
|
|
return fmt.Errorf("no input received")
|
|
}
|
|
|
|
// 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
|
|
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
|
|
}
|
|
|
|
// Extract conversation context
|
|
contextExtractor := conv.NewContextExtractor()
|
|
conversationContext, err := contextExtractor.ExtractRecentContext(input.TranscriptPath, input.SessionID)
|
|
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",
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
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")
|
|
}
|