Add AI coding tool hooks integration (Claude Code, Codex, Cursor)#257
Add AI coding tool hooks integration (Claude Code, Codex, Cursor)#257
Conversation
Add new `shelltime aicode-hooks` CLI command and daemon processor for hooks-based tracking of AI coding tools (Claude Code, Codex, Cursor). - New CLI command reads hook JSON from stdin, enriches with metadata - Socket message type for AICodeHooks with pub/sub topic - Daemon processor with batching (50 events / 5s window) - Install/uninstall commands for hook configuration - API client for POST /api/v1/cc/aicode-hooks - Config support for aiCodeHooks section https://claude.ai/code/session_01D711AM5UncwjVcqrov7YMR
…yload Add NotificationType, NotificationMessage, and SessionEndReason fields to match the server schema, and extract them from raw hook JSON. https://claude.ai/code/session_01D711AM5UncwjVcqrov7YMR
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
Summary of ChangesHello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request introduces a robust system for tracking events from various AI coding tools like Claude Code, Codex, and Cursor. It provides a new command-line interface for managing tool-specific hooks, a daemon-side processor for efficient event handling and batching, and comprehensive data models and API integration to ensure these events are reliably sent to the backend for analysis. The changes aim to enhance observability into AI-assisted development workflows. Highlights
Changelog
Activity
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here. You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension. Footnotes
|
| go func() { | ||
| sendCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) | ||
| defer cancel() | ||
| _, err := model.SendAICodeHooksData(sendCtx, req, endpoint) | ||
| if err != nil { | ||
| slog.Error("AICodeHooks: Direct HTTP send failed", slog.Any("err", err)) | ||
| } | ||
| }() |
There was a problem hiding this comment.
🔴 Fire-and-forget goroutine in CLI process exits before HTTP request completes
In sendAICodeHooksDirect, the HTTP request is dispatched in a background goroutine (go func() at line 196). However, this function is called from the CLI command handler commandAICodeHooks which returns nil immediately after at commands/aicode_hooks.go:160. Once the command handler returns, app.Run() in cmd/cli/main.go:108 completes, CloseLogger() runs, and the process exits — killing the goroutine before the HTTP request can complete. This means the direct HTTP fallback path (used when the daemon socket is unavailable) will silently lose all event data. The fix is to make the HTTP call synchronous (remove the go func() wrapper), since the hook is already registered as async: true in Claude Code's settings, so Claude Code won't be blocked waiting for the shelltime process.
| go func() { | |
| sendCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) | |
| defer cancel() | |
| _, err := model.SendAICodeHooksData(sendCtx, req, endpoint) | |
| if err != nil { | |
| slog.Error("AICodeHooks: Direct HTTP send failed", slog.Any("err", err)) | |
| } | |
| }() | |
| sendCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) | |
| defer cancel() | |
| _, err := model.SendAICodeHooksData(sendCtx, req, endpoint) | |
| if err != nil { | |
| slog.Error("AICodeHooks: Direct HTTP send failed", slog.Any("err", err)) | |
| } |
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Code Review
This pull request introduces a new feature for tracking AI coding tool hook events, including a CLI command (aicode-hooks) to process incoming events from stdin, daemon-side processing for batching and sending these events to a backend, and install/uninstall subcommands to configure AI tools like Claude Code, Codex, and Cursor to trigger these hooks. Review comments highlight several areas for improvement: ensuring platform-independent path handling by using os.UserHomeDir() and filepath.Join instead of os.ExpandEnv("$HOME") or os.Getenv("HOME"), refactoring repetitive JSON parsing in commandAICodeHooks for better readability and maintainability (ideally by direct unmarshaling or using a library like mapstructure), simplifying conditional logic in uninstallGenericHooks using strings.HasPrefix, and improving efficiency in AICodeHooksProcessor by directly decoding socketMsg.Payload into AICodeHooksEventData using mapstructure instead of inefficient marshal/unmarshal cycles.
| func commandAICodeHooks(c *cli.Context) error { | ||
| ctx, span := commandTracer.Start(c.Context, "aicode-hooks", trace.WithSpanKind(trace.SpanKindClient)) | ||
| defer span.End() | ||
| SetupLogger(os.ExpandEnv("$HOME/" + model.COMMAND_BASE_STORAGE_FOLDER)) |
There was a problem hiding this comment.
The use of os.ExpandEnv("$HOME/" + ...) is not platform-independent and violates the repository's general rules. On Windows, $HOME is not typically set. Please use os.UserHomeDir() to get the user's home directory and path/filepath.Join to construct the path.
For example:
homeDir, err := os.UserHomeDir()
if err != nil {
slog.Error("Failed to determine home directory", "err", err)
return err
}
SetupLogger(filepath.Join(homeDir, model.COMMAND_BASE_STORAGE_FOLDER))References
- For platform-independent paths, use
filepath.Jointo combine segments andos.UserHomeDir()to get the home directory, rather than hardcoding path separators or environment variables like$HOME.
| // Parse common fields from raw payload | ||
| if v, ok := rawPayload["hook_event_name"].(string); ok { | ||
| eventData.HookEventName = v | ||
| } | ||
| if v, ok := rawPayload["session_id"].(string); ok { | ||
| eventData.SessionID = v | ||
| } | ||
| if v, ok := rawPayload["cwd"].(string); ok { | ||
| eventData.Cwd = v | ||
| } | ||
| if v, ok := rawPayload["permission_mode"].(string); ok { | ||
| eventData.PermissionMode = v | ||
| } | ||
| if v, ok := rawPayload["model"].(string); ok { | ||
| eventData.Model = v | ||
| } | ||
| if v, ok := rawPayload["tool_name"].(string); ok { | ||
| eventData.ToolName = v | ||
| } | ||
| if v, ok := rawPayload["tool_input"].(map[string]any); ok { | ||
| eventData.ToolInput = v | ||
| } | ||
| if v, ok := rawPayload["tool_response"].(map[string]any); ok { | ||
| eventData.ToolResponse = v | ||
| } | ||
| if v, ok := rawPayload["tool_use_id"].(string); ok { | ||
| eventData.ToolUseID = v | ||
| } | ||
| if v, ok := rawPayload["prompt"].(string); ok { | ||
| eventData.Prompt = v | ||
| } | ||
| if v, ok := rawPayload["error"].(string); ok { | ||
| eventData.Error = v | ||
| } | ||
| if v, ok := rawPayload["is_interrupt"].(bool); ok { | ||
| eventData.IsInterrupt = v | ||
| } | ||
| if v, ok := rawPayload["agent_id"].(string); ok { | ||
| eventData.AgentID = v | ||
| } | ||
| if v, ok := rawPayload["agent_type"].(string); ok { | ||
| eventData.AgentType = v | ||
| } | ||
| if v, ok := rawPayload["last_assistant_message"].(string); ok { | ||
| eventData.LastMessage = v | ||
| } | ||
| if v, ok := rawPayload["stop_hook_active"].(bool); ok { | ||
| eventData.StopHookActive = v | ||
| } | ||
| if v, ok := rawPayload["notification_type"].(string); ok { | ||
| eventData.NotificationType = v | ||
| } | ||
| if v, ok := rawPayload["notification_message"].(string); ok { | ||
| eventData.NotificationMessage = v | ||
| } | ||
| if v, ok := rawPayload["session_end_reason"].(string); ok { | ||
| eventData.SessionEndReason = v | ||
| } | ||
| if v, ok := rawPayload["transcript_path"].(string); ok { | ||
| eventData.TranscriptPath = v | ||
| } |
There was a problem hiding this comment.
This large block of type assertions to parse fields from rawPayload is repetitive and can be hard to maintain. Consider refactoring this to improve readability and reduce boilerplate. One approach is to define helper functions for extracting typed values from the map. For example:
func getString(payload map[string]any, key string) string {
if v, ok := payload[key].(string); ok {
return v
}
return ""
}
// ... in commandAICodeHooks ...
eventData.HookEventName = getString(rawPayload, "hook_event_name")An even better approach would be to unmarshal the JSON directly into the model.AICodeHooksEventData struct, which would eliminate this manual parsing entirely.
|
|
||
| // installClaudeCodeHooks reads ~/.claude/settings.json, merges hooks, and writes back | ||
| func installClaudeCodeHooks() error { | ||
| settingsPath := filepath.Join(os.Getenv("HOME"), ".claude", "settings.json") |
There was a problem hiding this comment.
The use of os.Getenv("HOME") is not platform-independent and violates the repository's general rules. Please use os.UserHomeDir() to get the user's home directory. This issue is present in multiple functions in this file (e.g., lines 241, 297, 303, 309, 315).
A fix would look like this:
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("could not get user home directory: %w", err)
}
settingsPath := filepath.Join(homeDir, ".claude", "settings.json")References
- For platform-independent paths, use
filepath.Jointo combine segments andos.UserHomeDir()to get the home directory, rather than hardcoding path separators or environment variables like$HOME.
| if cmd == "shelltime aicode-hooks" || | ||
| cmd == "shelltime aicode-hooks --source=codex" || | ||
| cmd == "shelltime aicode-hooks --source=cursor" { | ||
| continue // Skip shelltime hooks | ||
| } |
| payloadBytes, err := json.Marshal(socketMsg.Payload) | ||
| if err != nil { | ||
| slog.Error("AICodeHooks: Failed to marshal payload", slog.Any("err", err)) | ||
| return | ||
| } | ||
|
|
||
| var eventData model.AICodeHooksEventData | ||
| if err := json.Unmarshal(payloadBytes, &eventData); err != nil { | ||
| slog.Error("AICodeHooks: Failed to parse event data", slog.Any("err", err)) | ||
| return | ||
| } |
There was a problem hiding this comment.
This block of code marshals a map[string]interface{} to JSON and then immediately unmarshals it back to a struct. This is inefficient. A better approach is to use a library like github.com/mitchellh/mapstructure to directly decode the map into the struct. You'll need to add import "github.com/mitchellh/mapstructure" to your imports.
| payloadBytes, err := json.Marshal(socketMsg.Payload) | |
| if err != nil { | |
| slog.Error("AICodeHooks: Failed to marshal payload", slog.Any("err", err)) | |
| return | |
| } | |
| var eventData model.AICodeHooksEventData | |
| if err := json.Unmarshal(payloadBytes, &eventData); err != nil { | |
| slog.Error("AICodeHooks: Failed to parse event data", slog.Any("err", err)) | |
| return | |
| } | |
| var eventData model.AICodeHooksEventData | |
| if err := mapstructure.Decode(socketMsg.Payload, &eventData); err != nil { | |
| slog.Error("AICodeHooks: Failed to parse event data", slog.Any("err", err)) | |
| return | |
| } |
Summary
This PR adds comprehensive support for tracking AI coding tool hook events from Claude Code, Codex, and Cursor. It includes installation/uninstallation commands, event processing infrastructure, and backend integration.
Key Changes
New Commands
aicode-hookscommand: Main command to track hook events from AI coding toolsinstallanduninstallsubcommands for hook setupaicode-hooks install/uninstallsubcommands: Manage hook registration~/.claude/settings.jsonto register hooks for 10 events (SessionStart, UserPromptSubmit, PostToolUse, etc.)~/.codex/hooks.jsonand~/.cursor/hooks.jsonDaemon-Side Processing
AICodeHooksProcessor: New daemon component for batching and forwarding eventsaicode_hookstopic via pub/subData Models
AICodeHooksEventData: Comprehensive event structure capturing:AICodeHooksRequest/Response: API contract for backend communicationSocket Integration
SocketMessageTypeAICodeHooksmessage typeSendAICodeHooksToSocket()function for fire-and-forget event deliveryConfiguration
AICodeHooksconfig struct with:EnabledflagBatchSize(default: 50)BatchIntervalSeconds(default: 5)Debugflag for troubleshootingAPI Integration
SendAICodeHooksData(): HTTP POST to/api/v1/cc/aicode-hooksendpointImplementation Details
/tmp/shelltime/aicode-hooks-debug-events.txthttps://claude.ai/code/session_01D711AM5UncwjVcqrov7YMR