From 2e7a2badba64922934c750e23e1cfe96ee8e635f Mon Sep 17 00:00:00 2001 From: Jason Hall Date: Mon, 21 Jul 2025 10:12:28 -0400 Subject: [PATCH] Initial implementation of Claude Code hooks in Go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Easy-to-use hook registration system - Built-in hooks for bash validation, file protection, and logging - Automatic goimports formatting for modified Go files - CLI with install/uninstall commands - Comprehensive documentation and examples 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .gitignore | 32 ++++++ README.md | 169 +++++++++++++++++++++++++++++++ cmd/install.go | 71 +++++++++++++ cmd/root.go | 39 +++++++ cmd/run.go | 31 ++++++ go.mod | 9 ++ go.sum | 10 ++ internal/config/settings.go | 126 +++++++++++++++++++++++ internal/handlers/goimports.go | 57 +++++++++++ internal/handlers/handlers.go | 14 +++ internal/handlers/posttooluse.go | 46 +++++++++ internal/handlers/pretooluse.go | 84 +++++++++++++++ internal/handlers/userprompt.go | 70 +++++++++++++ main.go | 9 ++ 14 files changed, 767 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 cmd/install.go create mode 100644 cmd/root.go create mode 100644 cmd/run.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/config/settings.go create mode 100644 internal/handlers/goimports.go create mode 100644 internal/handlers/handlers.go create mode 100644 internal/handlers/posttooluse.go create mode 100644 internal/handlers/pretooluse.go create mode 100644 internal/handlers/userprompt.go create mode 100644 main.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..26ee077 --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work +go.work.sum + +# Binary +hooks + +# IDE files +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS files +.DS_Store \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..f231015 --- /dev/null +++ b/README.md @@ -0,0 +1,169 @@ +# Claude Code Hooks + +A Go binary that handles Claude Code hooks for validating and modifying tool usage. This tool makes it easy to write custom hooks as simple Go functions. + +## Installation + +```bash +go build -o hooks +./hooks install +``` + +This will configure your `~/.claude/settings.json` to use this binary for all hook events. + +## Usage + +### Writing Custom Hooks + +Hooks are simple Go functions that follow this signature: + +```go +func MyHook(ctx context.Context, input hooks.HookInput) (hooks.HookOutput, error) { + // Your logic here + return hooks.HookOutput{Decision: "continue"}, nil +} +``` + +Register your hook in an init function: + +```go +func init() { + // Register for specific tools + hooks.RegisterHook(hooks.EventPreToolUse, "Bash", ValidateBashCommand) + + // Use regex patterns + hooks.RegisterHook(hooks.EventPreToolUse, "Write|Edit", PreventSensitiveEdits) + + // Match all tools + hooks.RegisterHook(hooks.EventPostToolUse, "*", LogEverything) +} +``` + +### Example: Block Dangerous Commands + +```go +func BlockRmRf(ctx context.Context, input hooks.HookInput) (hooks.HookOutput, error) { + cmd := input.ToolUseRequest.Parameters["command"].(string) + if strings.Contains(cmd, "rm -rf /") { + return hooks.HookOutput{ + Decision: "block", + Reason: "Cannot execute rm -rf on root directory", + }, nil + } + return hooks.HookOutput{Decision: "continue"}, nil +} +``` + +### Example: Add Context to Prompts + +```go +func AddGitInfo(ctx context.Context, input hooks.HookInput) (hooks.HookOutput, error) { + branch := getCurrentGitBranch() + return hooks.HookOutput{ + Decision: "continue", + AdditionalContext: fmt.Sprintf("Current git branch: %s", branch), + }, nil +} +``` + +### Example: Modify Tool Parameters + +```go +func ForceNonInteractiveSudo(ctx context.Context, input hooks.HookInput) (hooks.HookOutput, error) { + cmd := input.ToolUseRequest.Parameters["command"].(string) + if strings.Contains(cmd, "sudo") && !strings.Contains(cmd, "sudo -n") { + return hooks.HookOutput{ + Decision: "continue", + ModifiedParameters: map[string]interface{}{ + "command": strings.ReplaceAll(cmd, "sudo", "sudo -n"), + }, + }, nil + } + return hooks.HookOutput{Decision: "continue"}, nil +} +``` + +## Built-in Hooks + +This package includes several example hooks: + +### Pre Tool Use +- **Bash Command Validator**: Blocks dangerous commands like `rm -rf /` +- **Sensitive File Protection**: Prevents editing `.env`, `.aws/credentials`, etc. + +### Post Tool Use +- **Tool Usage Logger**: Logs all tool executions with structured logging +- **Go Imports**: Automatically runs `goimports -w` on modified Go files + +### User Prompt Submit +- **Project Context**: Adds git branch and project type information + +## Testing Hooks + +Test individual hooks by piping JSON: + +```bash +echo '{ + "event": "pre_tool_use", + "tool": "Bash", + "tool_use_request": { + "tool": "Bash", + "parameters": { + "command": "rm -rf /" + } + } +}' | ./hooks run +``` + +Enable debug logging: + +```bash +./hooks run --debug +``` + +## Hook Events + +- `pre_tool_use`: Before any tool execution +- `post_tool_use`: After successful tool execution +- `user_prompt_submit`: Before processing user prompts +- `stop`: When main agent finishes +- `subagent_stop`: When subagent finishes +- `notification`: For permission requests +- `pre_compact`: Before context compaction + +## Configuration + +Hooks are configured in `~/.claude/settings.json`: + +```json +{ + "hooks": [ + { + "events": ["pre_tool_use", "post_tool_use"], + "matchers": [".*"], + "cmds": ["/path/to/hooks run"] + } + ] +} +``` + +## Security Warning + +⚠️ **USE AT YOUR OWN RISK**: Hooks execute automatically when Claude Code runs tools. Ensure your hooks are thoroughly tested and secure. + +## Development + +To add new hooks: + +1. Create a new file in `internal/handlers/` +2. Write your hook function +3. Register it in an `init()` function +4. Rebuild and reinstall: `go build && ./hooks install` + +## Uninstalling + +```bash +./hooks install --uninstall +``` + +This removes all hook configurations from your Claude settings. \ No newline at end of file diff --git a/cmd/install.go b/cmd/install.go new file mode 100644 index 0000000..9334479 --- /dev/null +++ b/cmd/install.go @@ -0,0 +1,71 @@ +package cmd + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + + "github.com/spf13/cobra" + "github.com/imjasonh/hooks/internal/config" +) + +var ( + uninstall bool + installCmd = &cobra.Command{ + Use: "install", + Short: "Install hooks to Claude settings", + Long: `Install this binary as a hook handler in your Claude settings. + +This command will: +1. Find or create ~/.claude/settings.json +2. Add this binary to handle all hook events +3. Configure it to match all tools + +Use --uninstall to remove the hooks.`, + RunE: runInstall, + } +) + +func init() { + rootCmd.AddCommand(installCmd) + installCmd.Flags().BoolVar(&uninstall, "uninstall", false, "Remove hooks from Claude settings") +} + +func runInstall(cmd *cobra.Command, args []string) error { + executable, err := os.Executable() + if err != nil { + return fmt.Errorf("failed to get executable path: %w", err) + } + + executable, err = filepath.Abs(executable) + if err != nil { + return fmt.Errorf("failed to get absolute path: %w", err) + } + + if uninstall { + slog.Info("uninstalling hooks", "binary", executable) + if err := config.UninstallHooks(executable); err != nil { + return fmt.Errorf("failed to uninstall hooks: %w", err) + } + fmt.Println("✓ Hooks uninstalled successfully") + return nil + } + + slog.Info("installing hooks", "binary", executable) + if err := config.InstallHooks(executable); err != nil { + return fmt.Errorf("failed to install hooks: %w", err) + } + + settingsPath := config.GetSettingsPath() + fmt.Printf("✓ Hooks installed successfully\n") + fmt.Printf(" Binary: %s\n", executable) + fmt.Printf(" Settings: %s\n", settingsPath) + fmt.Printf("\nThe following hooks are now active:\n") + fmt.Println(" • pre_tool_use: Validates bash commands and prevents sensitive file edits") + fmt.Println(" • post_tool_use: Logs tool usage and runs goimports on modified Go files") + fmt.Println(" • user_prompt_submit: Adds project context to prompts") + fmt.Println("\nTo test: echo '{\"event\":\"pre_tool_use\",\"tool\":\"Bash\",\"tool_use_request\":{\"tool\":\"Bash\",\"parameters\":{\"command\":\"ls\"}}}' | hooks run") + + return nil +} \ No newline at end of file diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..9df9c05 --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,39 @@ +package cmd + +import ( + "fmt" + "log/slog" + "os" + + "github.com/spf13/cobra" +) + +var ( + debug bool + rootCmd = &cobra.Command{ + Use: "hooks", + Short: "Claude Code hooks runner", + Long: `A Go binary that handles Claude Code hooks for validating and modifying tool usage. + +This tool makes it easy to write custom hooks as simple Go functions and automatically +register them to run when Claude Code executes various tools.`, + 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)) + } + }, + } +) + +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") +} \ No newline at end of file diff --git a/cmd/run.go b/cmd/run.go new file mode 100644 index 0000000..81db32b --- /dev/null +++ b/cmd/run.go @@ -0,0 +1,31 @@ +package cmd + +import ( + "context" + "os" + + "github.com/spf13/cobra" + "github.com/imjasonh/hooks/internal/hooks" + _ "github.com/imjasonh/hooks/internal/handlers" +) + +var runCmd = &cobra.Command{ + Use: "run", + Short: "Run hook handler (called by Claude Code)", + Long: `Run the hook handler by reading JSON from stdin and processing it. + +This command is typically called automatically by Claude Code when hooks are triggered. +You can also use it for testing by piping JSON input.`, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + code := hooks.RunExitCode(ctx) + if code != 0 { + os.Exit(code) + } + return nil + }, +} + +func init() { + rootCmd.AddCommand(runCmd) +} \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2ece09d --- /dev/null +++ b/go.mod @@ -0,0 +1,9 @@ +module github.com/imjasonh/hooks + +go 1.24.4 + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/cobra v1.9.1 // indirect + github.com/spf13/pflag v1.0.6 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ffae55e --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= +github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/settings.go b/internal/config/settings.go new file mode 100644 index 0000000..016dd99 --- /dev/null +++ b/internal/config/settings.go @@ -0,0 +1,126 @@ +package config + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +type Settings struct { + Hooks []Hook `json:"hooks"` +} + +type Hook struct { + Events []string `json:"events"` + Matchers []string `json:"matchers"` + Cmds []string `json:"cmds"` +} + +func LoadSettings(path string) (*Settings, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return &Settings{}, nil + } + return nil, fmt.Errorf("failed to read settings: %w", err) + } + + var settings Settings + if err := json.Unmarshal(data, &settings); err != nil { + return nil, fmt.Errorf("failed to parse settings: %w", err) + } + + return &settings, nil +} + +func SaveSettings(path string, settings *Settings) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create settings directory: %w", err) + } + + data, err := json.MarshalIndent(settings, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal settings: %w", err) + } + + if err := os.WriteFile(path, data, 0644); err != nil { + return fmt.Errorf("failed to write settings: %w", err) + } + + return nil +} + +func GetSettingsPath() string { + if path := os.Getenv("CLAUDE_SETTINGS_PATH"); path != "" { + return path + } + + home, err := os.UserHomeDir() + if err != nil { + return "" + } + + return filepath.Join(home, ".claude", "settings.json") +} + +func InstallHooks(binaryPath string) error { + settingsPath := GetSettingsPath() + settings, err := LoadSettings(settingsPath) + if err != nil { + return err + } + + hookCmd := binaryPath + " run" + + allEvents := []string{ + "pre_tool_use", + "post_tool_use", + "user_prompt_submit", + "stop", + "subagent_stop", + "notification", + "pre_compact", + } + + found := false + for i, hook := range settings.Hooks { + if len(hook.Cmds) > 0 && hook.Cmds[0] == hookCmd { + settings.Hooks[i].Events = allEvents + settings.Hooks[i].Matchers = []string{".*"} + found = true + break + } + } + + if !found { + settings.Hooks = append(settings.Hooks, Hook{ + Events: allEvents, + Matchers: []string{".*"}, + Cmds: []string{hookCmd}, + }) + } + + return SaveSettings(settingsPath, settings) +} + +func UninstallHooks(binaryPath string) error { + settingsPath := GetSettingsPath() + settings, err := LoadSettings(settingsPath) + if err != nil { + return err + } + + hookCmd := binaryPath + " run" + filtered := make([]Hook, 0) + + for _, hook := range settings.Hooks { + if len(hook.Cmds) == 0 || hook.Cmds[0] != hookCmd { + filtered = append(filtered, hook) + } + } + + settings.Hooks = filtered + return SaveSettings(settingsPath, settings) +} \ No newline at end of file diff --git a/internal/handlers/goimports.go b/internal/handlers/goimports.go new file mode 100644 index 0000000..8f1906a --- /dev/null +++ b/internal/handlers/goimports.go @@ -0,0 +1,57 @@ +package handlers + +import ( + "context" + "log/slog" + "os/exec" + "strings" + + "github.com/imjasonh/hooks/internal/hooks" +) + +func init() { + hooks.RegisterHook(hooks.EventPostToolUse, "Write|Edit|MultiEdit", RunGoImportsOnGoFiles) +} + +func RunGoImportsOnGoFiles(ctx context.Context, input hooks.HookInput) (hooks.HookOutput, error) { + // Extract file path from parameters + filePath, ok := input.ToolUseRequest.Parameters["file_path"].(string) + if !ok { + return hooks.HookOutput{Decision: "continue"}, nil + } + + // Check if it's a Go file + if !strings.HasSuffix(filePath, ".go") { + return hooks.HookOutput{Decision: "continue"}, nil + } + + // Check if goimports is available + if _, err := exec.LookPath("goimports"); err != nil { + slog.Debug("goimports not found in PATH", "error", err) + return hooks.HookOutput{Decision: "continue"}, nil + } + + // Run goimports -w on the file + cmd := exec.CommandContext(ctx, "goimports", "-w", filePath) + output, err := cmd.CombinedOutput() + + if err != nil { + slog.Error("goimports failed", + "file", filePath, + "error", err, + "output", string(output)) + // Don't block on goimports errors + return hooks.HookOutput{Decision: "continue"}, nil + } + + if len(output) > 0 { + slog.Info("goimports applied changes", + "file", filePath, + "output", string(output)) + } else { + slog.Info("goimports completed", + "file", filePath) + } + + return hooks.HookOutput{Decision: "continue"}, nil +} \ No newline at end of file diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go new file mode 100644 index 0000000..17b16dc --- /dev/null +++ b/internal/handlers/handlers.go @@ -0,0 +1,14 @@ +package handlers + +import ( + "github.com/imjasonh/hooks/internal/hooks" +) + +func init() { + hooks.RegisterHook(hooks.EventPreToolUse, "Bash", ValidateBashCommand) + hooks.RegisterHook(hooks.EventPreToolUse, "Write|Edit|MultiEdit", PreventSensitiveFileEdits) + + hooks.RegisterHook(hooks.EventPostToolUse, "*", LogToolUsage) + + hooks.RegisterHook(hooks.EventUserPromptSubmit, "*", AddProjectContext) +} \ No newline at end of file diff --git a/internal/handlers/posttooluse.go b/internal/handlers/posttooluse.go new file mode 100644 index 0000000..4a1fa37 --- /dev/null +++ b/internal/handlers/posttooluse.go @@ -0,0 +1,46 @@ +package handlers + +import ( + "context" + "log/slog" + "time" + + "github.com/imjasonh/hooks/internal/hooks" +) + +func LogToolUsage(ctx context.Context, input hooks.HookInput) (hooks.HookOutput, error) { + params := input.ToolUseRequest.Parameters + + switch input.Tool { + case "Bash": + if cmd, ok := params["command"].(string); ok { + slog.Info("bash command executed", + "command", cmd, + "session_id", input.SessionID, + "cwd", input.CWD, + "timestamp", time.Now().Unix()) + } + case "Write", "Edit", "MultiEdit": + if path, ok := params["file_path"].(string); ok { + slog.Info("file modified", + "tool", input.Tool, + "file", path, + "session_id", input.SessionID, + "timestamp", time.Now().Unix()) + } + case "Read": + if path, ok := params["file_path"].(string); ok { + slog.Info("file read", + "file", path, + "session_id", input.SessionID, + "timestamp", time.Now().Unix()) + } + default: + slog.Info("tool used", + "tool", input.Tool, + "session_id", input.SessionID, + "timestamp", time.Now().Unix()) + } + + return hooks.HookOutput{Decision: "continue"}, nil +} \ No newline at end of file diff --git a/internal/handlers/pretooluse.go b/internal/handlers/pretooluse.go new file mode 100644 index 0000000..88e92b5 --- /dev/null +++ b/internal/handlers/pretooluse.go @@ -0,0 +1,84 @@ +package handlers + +import ( + "context" + "fmt" + "log/slog" + "strings" + + "github.com/imjasonh/hooks/internal/hooks" +) + +var dangerousCommands = []string{ + "rm -rf /", + "rm -rf /*", + ":(){ :|:& };:", + "mkfs.", + "dd if=/dev/zero", + "> /dev/sda", + "wget http", + "curl http", +} + +func ValidateBashCommand(ctx context.Context, input hooks.HookInput) (hooks.HookOutput, error) { + cmd, ok := input.ToolUseRequest.Parameters["command"].(string) + if !ok { + return hooks.HookOutput{Decision: "continue"}, nil + } + + for _, dangerous := range dangerousCommands { + if strings.Contains(cmd, dangerous) { + slog.Warn("blocked dangerous command", + "command", cmd, + "pattern", dangerous) + return hooks.HookOutput{ + Decision: "block", + Reason: fmt.Sprintf("Command contains dangerous pattern: %s", dangerous), + }, nil + } + } + + if strings.Contains(cmd, "sudo") && !strings.Contains(cmd, "sudo -n") { + slog.Info("modifying sudo command to non-interactive") + return hooks.HookOutput{ + Decision: "continue", + ModifiedParameters: map[string]interface{}{ + "command": strings.ReplaceAll(cmd, "sudo", "sudo -n"), + }, + }, nil + } + + return hooks.HookOutput{Decision: "continue"}, nil +} + +var sensitiveFiles = []string{ + ".env", + ".aws/credentials", + ".ssh/id_rsa", + "secrets", + "password", + "token", + "key.pem", +} + +func PreventSensitiveFileEdits(ctx context.Context, input hooks.HookInput) (hooks.HookOutput, error) { + path, ok := input.ToolUseRequest.Parameters["file_path"].(string) + if !ok { + return hooks.HookOutput{Decision: "continue"}, nil + } + + lowerPath := strings.ToLower(path) + for _, sensitive := range sensitiveFiles { + if strings.Contains(lowerPath, strings.ToLower(sensitive)) { + slog.Warn("blocked sensitive file edit", + "file", path, + "pattern", sensitive) + return hooks.HookOutput{ + Decision: "block", + Reason: fmt.Sprintf("Cannot edit sensitive file: %s", path), + }, nil + } + } + + return hooks.HookOutput{Decision: "continue"}, nil +} \ No newline at end of file diff --git a/internal/handlers/userprompt.go b/internal/handlers/userprompt.go new file mode 100644 index 0000000..d25cd4e --- /dev/null +++ b/internal/handlers/userprompt.go @@ -0,0 +1,70 @@ +package handlers + +import ( + "context" + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" + + "github.com/imjasonh/hooks/internal/hooks" +) + +func AddProjectContext(ctx context.Context, input hooks.HookInput) (hooks.HookOutput, error) { + var contexts []string + + if gitRoot := findGitRoot(input.CWD); gitRoot != "" { + contexts = append(contexts, fmt.Sprintf("Git repository root: %s", gitRoot)) + + if branch := getCurrentBranch(gitRoot); branch != "" { + contexts = append(contexts, fmt.Sprintf("Current branch: %s", branch)) + } + } + + if _, err := os.Stat(filepath.Join(input.CWD, "go.mod")); err == nil { + contexts = append(contexts, "Project type: Go module") + } else if _, err := os.Stat(filepath.Join(input.CWD, "package.json")); err == nil { + contexts = append(contexts, "Project type: Node.js/npm") + } else if _, err := os.Stat(filepath.Join(input.CWD, "Cargo.toml")); err == nil { + contexts = append(contexts, "Project type: Rust/Cargo") + } + + if len(contexts) > 0 { + additionalContext := "Project context:\n" + strings.Join(contexts, "\n") + slog.Info("adding project context", "context_lines", len(contexts)) + return hooks.HookOutput{ + Decision: "continue", + AdditionalContext: additionalContext, + }, nil + } + + return hooks.HookOutput{Decision: "continue"}, nil +} + +func findGitRoot(dir string) string { + current := dir + for { + if _, err := os.Stat(filepath.Join(current, ".git")); err == nil { + return current + } + parent := filepath.Dir(current) + if parent == current { + return "" + } + current = parent + } +} + +func getCurrentBranch(gitRoot string) string { + headPath := filepath.Join(gitRoot, ".git", "HEAD") + content, err := os.ReadFile(headPath) + if err != nil { + return "" + } + ref := strings.TrimSpace(string(content)) + if strings.HasPrefix(ref, "ref: refs/heads/") { + return strings.TrimPrefix(ref, "ref: refs/heads/") + } + return "" +} \ No newline at end of file diff --git a/main.go b/main.go new file mode 100644 index 0000000..d1f7463 --- /dev/null +++ b/main.go @@ -0,0 +1,9 @@ +package main + +import ( + "github.com/imjasonh/hooks/cmd" +) + +func main() { + cmd.Execute() +} \ No newline at end of file