diff --git a/cmd/cli/main.go b/cmd/cli/main.go index 19d752e..657f6bc 100644 --- a/cmd/cli/main.go +++ b/cmd/cli/main.go @@ -103,6 +103,7 @@ func main() { commands.SchemaCommand, commands.GrepCommand, commands.ConfigCommand, + commands.AICodeHooksCommand, } err = app.Run(os.Args) if err != nil { diff --git a/cmd/daemon/main.go b/cmd/daemon/main.go index abfd96a..7d4aac0 100644 --- a/cmd/daemon/main.go +++ b/cmd/daemon/main.go @@ -137,6 +137,14 @@ func main() { } } + // AICode Hooks processor (hooks-based tracking) + if cfg.AICodeHooks != nil && cfg.AICodeHooks.Enabled != nil && *cfg.AICodeHooks.Enabled { + aicodeHooksProcessor := daemon.NewAICodeHooksProcessor(cfg) + aicodeHooksProcessor.Start(pubsub) + defer aicodeHooksProcessor.Stop() + slog.Info("AICodeHooks processor started") + } + // Start heartbeat resync service if codeTracking is enabled if cfg.CodeTracking != nil && cfg.CodeTracking.Enabled != nil && *cfg.CodeTracking.Enabled { heartbeatResyncService := daemon.NewHeartbeatResyncService(cfg) diff --git a/commands/aicode_hooks.go b/commands/aicode_hooks.go new file mode 100644 index 0000000..17e998e --- /dev/null +++ b/commands/aicode_hooks.go @@ -0,0 +1,204 @@ +package commands + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "os" + "time" + + "github.com/google/uuid" + "github.com/malamtime/cli/daemon" + "github.com/malamtime/cli/model" + "github.com/urfave/cli/v2" + "go.opentelemetry.io/otel/trace" +) + +var AICodeHooksCommand = &cli.Command{ + Name: "aicode-hooks", + Usage: "Track AI coding tool hook events", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "source", + Value: "claude-code", + Usage: "Source of the hook event (claude-code, codex, cursor)", + }, + }, + Action: commandAICodeHooks, + Subcommands: []*cli.Command{ + AICodeHooksInstallCommand, + AICodeHooksUninstallCommand, + }, +} + +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)) + + // Read JSON from stdin + input, err := io.ReadAll(os.Stdin) + if err != nil { + slog.Error("Failed to read stdin", slog.Any("err", err)) + return err + } + + if len(input) == 0 { + slog.Debug("No input received from stdin") + return nil + } + + // Parse raw JSON payload + var rawPayload map[string]any + if err := json.Unmarshal(input, &rawPayload); err != nil { + slog.Error("Failed to parse JSON input", slog.Any("err", err)) + return err + } + + // Detect source + source := c.String("source") + if !c.IsSet("source") { + // Auto-detect: if JSON has hook_event_name, it's claude-code (default) + if _, ok := rawPayload["hook_event_name"]; ok { + source = model.AICodeHooksSourceClaudeCode + } + } + + // Map source to client type + clientType := mapSourceToClientType(source) + + // Build event data + eventData := model.AICodeHooksEventData{ + EventID: uuid.New().String(), + ClientType: clientType, + Timestamp: time.Now().Unix(), + RawPayload: rawPayload, + } + + // 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 + } + + // Try sending to daemon socket first + config, err := configService.ReadConfigFile(ctx) + if err != nil { + slog.Error("Failed to read config", slog.Any("err", err)) + return err + } + + socketPath := config.SocketPath + if daemon.IsSocketReady(ctx, socketPath) { + err := sendAICodeHooksToSocket(ctx, socketPath, eventData) + if err != nil { + slog.Error("Failed to send to daemon socket, trying direct HTTP", slog.Any("err", err)) + sendAICodeHooksDirect(ctx, config, eventData) + } + } else { + slog.Debug("Daemon socket not available, sending direct HTTP") + sendAICodeHooksDirect(ctx, config, eventData) + } + + return nil +} + +func mapSourceToClientType(source string) string { + switch source { + case model.AICodeHooksSourceCodex: + return model.AICodeHooksClientCodex + case model.AICodeHooksSourceCursor: + return model.AICodeHooksClientCursor + default: + return model.AICodeHooksClientClaudeCode + } +} + +func sendAICodeHooksToSocket(ctx context.Context, socketPath string, eventData model.AICodeHooksEventData) error { + return daemon.SendAICodeHooksToSocket(socketPath, eventData) +} + +// sendAICodeHooksDirect sends event data directly via HTTP (fire-and-forget) +func sendAICodeHooksDirect(ctx context.Context, config model.ShellTimeConfig, eventData model.AICodeHooksEventData) { + hostname, _ := os.Hostname() + if hostname == "" { + hostname = "unknown" + } + + req := &model.AICodeHooksRequest{ + Host: hostname, + Events: []model.AICodeHooksEventData{eventData}, + } + + endpoint := model.Endpoint{ + Token: config.Token, + APIEndpoint: config.APIEndpoint, + } + + // Fire-and-forget + 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)) + } + }() +} diff --git a/commands/aicode_hooks_install.go b/commands/aicode_hooks_install.go new file mode 100644 index 0000000..6445990 --- /dev/null +++ b/commands/aicode_hooks_install.go @@ -0,0 +1,407 @@ +package commands + +import ( + "encoding/json" + "log/slog" + "os" + "path/filepath" + + "github.com/gookit/color" + "github.com/urfave/cli/v2" +) + +// claudeCodeHookEvents lists all hook events to register for Claude Code +var claudeCodeHookEvents = []string{ + "SessionStart", + "UserPromptSubmit", + "PostToolUse", + "PostToolUseFailure", + "Stop", + "SubagentStart", + "SubagentStop", + "SessionEnd", + "Notification", + "PreCompact", +} + +// hookEntry represents a single hook configuration entry +type hookEntry struct { + Type string `json:"type"` + Command string `json:"command"` + Async bool `json:"async"` +} + +var AICodeHooksInstallCommand = &cli.Command{ + Name: "install", + Aliases: []string{"i"}, + Usage: "Install AI coding tool hooks (Claude Code, Codex, Cursor)", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "claude-code", + Usage: "Install hooks for Claude Code only", + }, + &cli.BoolFlag{ + Name: "codex", + Usage: "Install hooks for Codex only", + }, + &cli.BoolFlag{ + Name: "cursor", + Usage: "Install hooks for Cursor only", + }, + &cli.BoolFlag{ + Name: "all", + Value: true, + Usage: "Install hooks for all supported tools", + }, + }, + Action: commandAICodeHooksInstall, +} + +var AICodeHooksUninstallCommand = &cli.Command{ + Name: "uninstall", + Aliases: []string{"u"}, + Usage: "Uninstall AI coding tool hooks (Claude Code, Codex, Cursor)", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "claude-code", + Usage: "Uninstall hooks for Claude Code only", + }, + &cli.BoolFlag{ + Name: "codex", + Usage: "Uninstall hooks for Codex only", + }, + &cli.BoolFlag{ + Name: "cursor", + Usage: "Uninstall hooks for Cursor only", + }, + &cli.BoolFlag{ + Name: "all", + Value: true, + Usage: "Uninstall hooks for all supported tools", + }, + }, + Action: commandAICodeHooksUninstall, +} + +func commandAICodeHooksInstall(c *cli.Context) error { + installClaudeCode := c.Bool("claude-code") + installCodex := c.Bool("codex") + installCursor := c.Bool("cursor") + installAll := c.Bool("all") + + // If specific tools are selected, don't install all + if installClaudeCode || installCodex || installCursor { + installAll = false + } + + color.Yellow.Println("Installing AI code hooks...") + + if installAll || installClaudeCode { + if err := installClaudeCodeHooks(); err != nil { + color.Red.Printf("Failed to install Claude Code hooks: %v\n", err) + } else { + color.Green.Println("Claude Code hooks installed successfully") + } + } + + if installAll || installCodex { + if err := installCodexHooks(); err != nil { + color.Red.Printf("Failed to install Codex hooks: %v\n", err) + } else { + color.Green.Println("Codex hooks installed successfully") + } + } + + if installAll || installCursor { + if err := installCursorHooks(); err != nil { + color.Red.Printf("Failed to install Cursor hooks: %v\n", err) + } else { + color.Green.Println("Cursor hooks installed successfully") + } + } + + color.Green.Println("AI code hooks installation complete!") + return nil +} + +func commandAICodeHooksUninstall(c *cli.Context) error { + uninstallClaudeCode := c.Bool("claude-code") + uninstallCodex := c.Bool("codex") + uninstallCursor := c.Bool("cursor") + uninstallAll := c.Bool("all") + + // If specific tools are selected, don't uninstall all + if uninstallClaudeCode || uninstallCodex || uninstallCursor { + uninstallAll = false + } + + color.Yellow.Println("Uninstalling AI code hooks...") + + if uninstallAll || uninstallClaudeCode { + if err := uninstallClaudeCodeHooks(); err != nil { + color.Red.Printf("Failed to uninstall Claude Code hooks: %v\n", err) + } else { + color.Green.Println("Claude Code hooks uninstalled successfully") + } + } + + if uninstallAll || uninstallCodex { + if err := uninstallCodexHooks(); err != nil { + color.Red.Printf("Failed to uninstall Codex hooks: %v\n", err) + } else { + color.Green.Println("Codex hooks uninstalled successfully") + } + } + + if uninstallAll || uninstallCursor { + if err := uninstallCursorHooks(); err != nil { + color.Red.Printf("Failed to uninstall Cursor hooks: %v\n", err) + } else { + color.Green.Println("Cursor hooks uninstalled successfully") + } + } + + color.Green.Println("AI code hooks uninstallation complete!") + return nil +} + +// installClaudeCodeHooks reads ~/.claude/settings.json, merges hooks, and writes back +func installClaudeCodeHooks() error { + settingsPath := filepath.Join(os.Getenv("HOME"), ".claude", "settings.json") + + // Read existing settings or create new + settings := make(map[string]any) + if data, err := os.ReadFile(settingsPath); err == nil { + if err := json.Unmarshal(data, &settings); err != nil { + slog.Error("Failed to parse Claude Code settings", slog.Any("err", err)) + return err + } + } + + // Get or create hooks map + hooks, ok := settings["hooks"].(map[string]any) + if !ok { + hooks = make(map[string]any) + } + + shelltimeHook := hookEntry{ + Type: "command", + Command: "shelltime aicode-hooks", + Async: true, + } + + // Add hooks for each event + for _, eventName := range claudeCodeHookEvents { + existingHooks, ok := hooks[eventName].([]any) + if !ok { + existingHooks = make([]any, 0) + } + + // Check if shelltime hook already exists + alreadyExists := false + for _, h := range existingHooks { + if hMap, ok := h.(map[string]any); ok { + if cmd, ok := hMap["command"].(string); ok && cmd == "shelltime aicode-hooks" { + alreadyExists = true + break + } + } + } + + if !alreadyExists { + // Convert hookEntry to map for JSON compatibility + hookMap := map[string]any{ + "type": shelltimeHook.Type, + "command": shelltimeHook.Command, + "async": shelltimeHook.Async, + } + existingHooks = append(existingHooks, hookMap) + hooks[eventName] = existingHooks + } + } + + settings["hooks"] = hooks + + // Ensure directory exists + if err := os.MkdirAll(filepath.Dir(settingsPath), 0755); err != nil { + return err + } + + // Write back + data, err := json.MarshalIndent(settings, "", " ") + if err != nil { + return err + } + + return os.WriteFile(settingsPath, data, 0644) +} + +// uninstallClaudeCodeHooks removes shelltime hooks from ~/.claude/settings.json +func uninstallClaudeCodeHooks() error { + settingsPath := filepath.Join(os.Getenv("HOME"), ".claude", "settings.json") + + data, err := os.ReadFile(settingsPath) + if err != nil { + if os.IsNotExist(err) { + return nil // Nothing to uninstall + } + return err + } + + settings := make(map[string]any) + if err := json.Unmarshal(data, &settings); err != nil { + return err + } + + hooks, ok := settings["hooks"].(map[string]any) + if !ok { + return nil // No hooks to remove + } + + for _, eventName := range claudeCodeHookEvents { + existingHooks, ok := hooks[eventName].([]any) + if !ok { + continue + } + + // Filter out shelltime hooks + filteredHooks := make([]any, 0) + for _, h := range existingHooks { + if hMap, ok := h.(map[string]any); ok { + if cmd, ok := hMap["command"].(string); ok && cmd == "shelltime aicode-hooks" { + continue // Skip shelltime hooks + } + } + filteredHooks = append(filteredHooks, h) + } + + if len(filteredHooks) == 0 { + delete(hooks, eventName) + } else { + hooks[eventName] = filteredHooks + } + } + + settings["hooks"] = hooks + + output, err := json.MarshalIndent(settings, "", " ") + if err != nil { + return err + } + + return os.WriteFile(settingsPath, output, 0644) +} + +// installCodexHooks creates/updates ~/.codex/hooks.json +func installCodexHooks() error { + hooksPath := filepath.Join(os.Getenv("HOME"), ".codex", "hooks.json") + return installGenericHooks(hooksPath, "codex") +} + +// uninstallCodexHooks removes shelltime hooks from ~/.codex/hooks.json +func uninstallCodexHooks() error { + hooksPath := filepath.Join(os.Getenv("HOME"), ".codex", "hooks.json") + return uninstallGenericHooks(hooksPath) +} + +// installCursorHooks creates/updates ~/.cursor/hooks.json +func installCursorHooks() error { + hooksPath := filepath.Join(os.Getenv("HOME"), ".cursor", "hooks.json") + return installGenericHooks(hooksPath, "cursor") +} + +// uninstallCursorHooks removes shelltime hooks from ~/.cursor/hooks.json +func uninstallCursorHooks() error { + hooksPath := filepath.Join(os.Getenv("HOME"), ".cursor", "hooks.json") + return uninstallGenericHooks(hooksPath) +} + +// installGenericHooks creates/updates a hooks.json file for Codex/Cursor style tools +func installGenericHooks(hooksPath string, source string) error { + hooksConfig := make(map[string]any) + if data, err := os.ReadFile(hooksPath); err == nil { + if err := json.Unmarshal(data, &hooksConfig); err != nil { + slog.Error("Failed to parse hooks config", slog.Any("err", err), slog.String("path", hooksPath)) + return err + } + } + + // Get or create hooks array + hooks, ok := hooksConfig["hooks"].([]any) + if !ok { + hooks = make([]any, 0) + } + + // Check if shelltime hook already exists + for _, h := range hooks { + if hMap, ok := h.(map[string]any); ok { + if cmd, ok := hMap["command"].(string); ok && cmd == "shelltime aicode-hooks --source="+source { + return nil // Already installed + } + } + } + + hookMap := map[string]any{ + "type": "command", + "command": "shelltime aicode-hooks --source=" + source, + "async": true, + } + hooks = append(hooks, hookMap) + hooksConfig["hooks"] = hooks + + // Ensure directory exists + if err := os.MkdirAll(filepath.Dir(hooksPath), 0755); err != nil { + return err + } + + data, err := json.MarshalIndent(hooksConfig, "", " ") + if err != nil { + return err + } + + return os.WriteFile(hooksPath, data, 0644) +} + +// uninstallGenericHooks removes shelltime hooks from a hooks.json file +func uninstallGenericHooks(hooksPath string) error { + data, err := os.ReadFile(hooksPath) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + + hooksConfig := make(map[string]any) + if err := json.Unmarshal(data, &hooksConfig); err != nil { + return err + } + + hooks, ok := hooksConfig["hooks"].([]any) + if !ok { + return nil + } + + filteredHooks := make([]any, 0) + for _, h := range hooks { + if hMap, ok := h.(map[string]any); ok { + if cmd, ok := hMap["command"].(string); ok { + if cmd == "shelltime aicode-hooks" || + cmd == "shelltime aicode-hooks --source=codex" || + cmd == "shelltime aicode-hooks --source=cursor" { + continue // Skip shelltime hooks + } + } + } + filteredHooks = append(filteredHooks, h) + } + + hooksConfig["hooks"] = filteredHooks + + output, err := json.MarshalIndent(hooksConfig, "", " ") + if err != nil { + return err + } + + return os.WriteFile(hooksPath, output, 0644) +} diff --git a/daemon/aicode_hooks_processor.go b/daemon/aicode_hooks_processor.go new file mode 100644 index 0000000..424e0b8 --- /dev/null +++ b/daemon/aicode_hooks_processor.go @@ -0,0 +1,235 @@ +package daemon + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "os" + "path/filepath" + "sync" + "time" + + "github.com/ThreeDotsLabs/watermill/message" + "github.com/malamtime/cli/model" +) + +const AICodeHooksTopic = "aicode_hooks" + +// AICodeHooksProcessor handles batching and forwarding hook events to the backend +type AICodeHooksProcessor struct { + config model.ShellTimeConfig + endpoint model.Endpoint + hostname string + debug bool + events []model.AICodeHooksEventData + mu sync.Mutex + timer *time.Timer + stopChan chan struct{} + + batchSize int + batchInterval time.Duration +} + +// NewAICodeHooksProcessor creates a new AICodeHooks processor +func NewAICodeHooksProcessor(config model.ShellTimeConfig) *AICodeHooksProcessor { + hostname, _ := os.Hostname() + if hostname == "" { + hostname = "unknown" + } + + debug := config.AICodeHooks != nil && config.AICodeHooks.Debug != nil && *config.AICodeHooks.Debug + + batchSize := 50 + batchInterval := 5 * time.Second + if config.AICodeHooks != nil { + if config.AICodeHooks.BatchSize > 0 { + batchSize = config.AICodeHooks.BatchSize + } + if config.AICodeHooks.BatchIntervalSeconds > 0 { + batchInterval = time.Duration(config.AICodeHooks.BatchIntervalSeconds) * time.Second + } + } + + return &AICodeHooksProcessor{ + config: config, + endpoint: model.Endpoint{ + Token: config.Token, + APIEndpoint: config.APIEndpoint, + }, + hostname: hostname, + debug: debug, + events: make([]model.AICodeHooksEventData, 0), + stopChan: make(chan struct{}), + batchSize: batchSize, + batchInterval: batchInterval, + } +} + +// Start subscribes to the AICodeHooks topic and begins processing messages +func (p *AICodeHooksProcessor) Start(goChannel *GoChannel) { + msgChan, err := goChannel.Subscribe(context.Background(), AICodeHooksTopic) + if err != nil { + slog.Error("AICodeHooks: Failed to subscribe to topic", slog.Any("err", err)) + return + } + + slog.Info("AICodeHooks: Processor started", slog.Int("batchSize", p.batchSize), slog.Duration("batchInterval", p.batchInterval)) + + go func() { + for { + select { + case msg, ok := <-msgChan: + if !ok { + slog.Info("AICodeHooks: Message channel closed") + return + } + p.processMessage(msg) + msg.Ack() + case <-p.stopChan: + slog.Info("AICodeHooks: Processor stopping") + return + } + } + }() +} + +// Stop flushes remaining events and stops the processor +func (p *AICodeHooksProcessor) Stop() { + close(p.stopChan) + + p.mu.Lock() + if p.timer != nil { + p.timer.Stop() + } + p.mu.Unlock() + + // Flush remaining events + p.flush() + slog.Info("AICodeHooks: Processor stopped") +} + +// processMessage parses a socket message and adds the event to the batch +func (p *AICodeHooksProcessor) processMessage(msg *message.Message) { + var socketMsg SocketMessage + if err := json.Unmarshal(msg.Payload, &socketMsg); err != nil { + slog.Error("AICodeHooks: Failed to parse socket message", slog.Any("err", err)) + return + } + + // Extract the event data from the socket message payload + 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 + } + + if p.debug { + p.writeDebugFile("aicode-hooks-debug-events.txt", eventData) + } + + slog.Debug("AICodeHooks: Received event", + slog.String("eventId", eventData.EventID), + slog.String("hookEventName", eventData.HookEventName), + slog.String("clientType", eventData.ClientType), + ) + + p.mu.Lock() + p.events = append(p.events, eventData) + + // Reset the flush timer + if p.timer != nil { + p.timer.Stop() + } + p.timer = time.AfterFunc(p.batchInterval, func() { + p.flush() + }) + + // Flush immediately if batch is full + if len(p.events) >= p.batchSize { + events := make([]model.AICodeHooksEventData, len(p.events)) + copy(events, p.events) + p.events = p.events[:0] + p.timer.Stop() + p.mu.Unlock() + p.sendEvents(events) + return + } + p.mu.Unlock() +} + +// flush sends all pending events to the backend +func (p *AICodeHooksProcessor) flush() { + p.mu.Lock() + if len(p.events) == 0 { + p.mu.Unlock() + return + } + events := make([]model.AICodeHooksEventData, len(p.events)) + copy(events, p.events) + p.events = p.events[:0] + p.mu.Unlock() + + p.sendEvents(events) +} + +// sendEvents sends a batch of events to the backend +func (p *AICodeHooksProcessor) sendEvents(events []model.AICodeHooksEventData) { + if len(events) == 0 { + return + } + + req := &model.AICodeHooksRequest{ + Host: p.hostname, + Events: events, + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + resp, err := model.SendAICodeHooksData(ctx, req, p.endpoint) + if err != nil { + slog.Error("AICodeHooks: Failed to send events to backend", slog.Any("err", err), slog.Int("eventCount", len(events))) + return + } + + slog.Debug("AICodeHooks: Events sent to backend", + slog.Int("eventsProcessed", resp.EventsProcessed), + slog.Bool("success", resp.Success), + ) +} + +// writeDebugFile appends JSON-formatted data to a debug file +func (p *AICodeHooksProcessor) writeDebugFile(filename string, data interface{}) { + debugDir := filepath.Join(os.TempDir(), "shelltime") + if err := os.MkdirAll(debugDir, 0755); err != nil { + slog.Error("AICodeHooks: Failed to create debug directory", "error", err) + return + } + + filePath := filepath.Join(debugDir, filename) + f, err := os.OpenFile(filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + slog.Error("AICodeHooks: Failed to open debug file", "error", err, "path", filePath) + return + } + defer f.Close() + + jsonData, err := json.MarshalIndent(data, "", " ") + if err != nil { + slog.Error("AICodeHooks: Failed to marshal debug data", "error", err) + return + } + + timestamp := time.Now().Format(time.RFC3339) + if _, err := f.WriteString(fmt.Sprintf("\n--- %s ---\n%s\n", timestamp, jsonData)); err != nil { + slog.Error("AICodeHooks: Failed to write debug data", "error", err) + } + slog.Debug("AICodeHooks: Wrote debug data", "path", filePath) +} diff --git a/daemon/client.go b/daemon/client.go index 77a146f..8008f34 100644 --- a/daemon/client.go +++ b/daemon/client.go @@ -70,6 +70,22 @@ func SendSessionProject(socketPath string, sessionID, projectPath string) { json.NewEncoder(conn).Encode(msg) } +// SendAICodeHooksToSocket sends an AI code hooks event to the daemon via socket (fire-and-forget) +func SendAICodeHooksToSocket(socketPath string, eventData model.AICodeHooksEventData) error { + conn, err := net.DialTimeout("unix", socketPath, 100*time.Millisecond) + if err != nil { + return err + } + defer conn.Close() + + msg := SocketMessage{ + Type: SocketMessageTypeAICodeHooks, + Payload: eventData, + } + + return json.NewEncoder(conn).Encode(msg) +} + // RequestCCInfo requests CC info (cost data and git info) from the daemon func RequestCCInfo(socketPath string, timeRange CCInfoTimeRange, workingDir string, timeout time.Duration) (*CCInfoResponse, error) { conn, err := net.DialTimeout("unix", socketPath, timeout) diff --git a/daemon/socket.go b/daemon/socket.go index 132ddb7..0c5f602 100644 --- a/daemon/socket.go +++ b/daemon/socket.go @@ -23,6 +23,7 @@ const ( SocketMessageTypeStatus SocketMessageType = "status" SocketMessageTypeCCInfo SocketMessageType = "cc_info" SocketMessageTypeSessionProject SocketMessageType = "session_project" + SocketMessageTypeAICodeHooks SocketMessageType = "aicode_hooks" ) type SessionProjectRequest struct { @@ -197,6 +198,16 @@ func (p *SocketHandler) handleConnection(conn net.Conn) { slog.Debug("session_project update dispatched", slog.String("sessionId", sessionID)) } } + case SocketMessageTypeAICodeHooks: + buf, err := json.Marshal(msg) + if err != nil { + slog.Error("Error encoding aicode_hooks message", slog.Any("err", err)) + return + } + chMsg := message.NewMessage(watermill.NewUUID(), buf) + if err := p.channel.Publish(AICodeHooksTopic, chMsg); err != nil { + slog.Error("Error publishing aicode_hooks topic", slog.Any("err", err)) + } default: slog.Error("Unknown message type:", slog.String("messageType", string(msg.Type))) } diff --git a/model/aicode_hooks_types.go b/model/aicode_hooks_types.go new file mode 100644 index 0000000..181a76d --- /dev/null +++ b/model/aicode_hooks_types.go @@ -0,0 +1,56 @@ +package model + +// AICodeHooksRequest is the main request to POST /api/v1/cc/aicode-hooks +type AICodeHooksRequest struct { + Host string `json:"host"` + Events []AICodeHooksEventData `json:"events"` +} + +// AICodeHooksEventData represents a single hook event from Claude Code, Codex, or Cursor +type AICodeHooksEventData struct { + EventID string `json:"eventId"` + ClientType string `json:"clientType"` + HookEventName string `json:"hookEventName"` + Timestamp int64 `json:"timestamp"` + SessionID string `json:"sessionId,omitempty"` + Cwd string `json:"cwd,omitempty"` + PermissionMode string `json:"permissionMode,omitempty"` + Model string `json:"model,omitempty"` + ToolName string `json:"toolName,omitempty"` + ToolInput map[string]any `json:"toolInput,omitempty"` + ToolResponse map[string]any `json:"toolResponse,omitempty"` + ToolUseID string `json:"toolUseId,omitempty"` + Prompt string `json:"prompt,omitempty"` + Error string `json:"error,omitempty"` + IsInterrupt bool `json:"isInterrupt,omitempty"` + AgentID string `json:"agentId,omitempty"` + AgentType string `json:"agentType,omitempty"` + LastMessage string `json:"lastMessage,omitempty"` + StopHookActive bool `json:"stopHookActive,omitempty"` + NotificationType string `json:"notificationType,omitempty"` + NotificationMessage string `json:"notificationMessage,omitempty"` + SessionEndReason string `json:"sessionEndReason,omitempty"` + TranscriptPath string `json:"transcriptPath,omitempty"` + RawPayload map[string]any `json:"rawPayload"` +} + +// AICodeHooksResponse is the response from POST /api/v1/cc/aicode-hooks +type AICodeHooksResponse struct { + Success bool `json:"success"` + EventsProcessed int `json:"eventsProcessed"` + Message string `json:"message,omitempty"` +} + +// AICode Hooks source identifiers +const ( + AICodeHooksSourceClaudeCode = "claude-code" + AICodeHooksSourceCodex = "codex" + AICodeHooksSourceCursor = "cursor" +) + +// AICode Hooks client type constants (for DB storage) +const ( + AICodeHooksClientClaudeCode = "claude_code" + AICodeHooksClientCodex = "codex" + AICodeHooksClientCursor = "cursor" +) diff --git a/model/api_aicode_hooks.go b/model/api_aicode_hooks.go new file mode 100644 index 0000000..00d6d0b --- /dev/null +++ b/model/api_aicode_hooks.go @@ -0,0 +1,31 @@ +package model + +import ( + "context" + "net/http" + "time" +) + +// SendAICodeHooksData sends hook event data to the backend +// POST /api/v1/cc/aicode-hooks +func SendAICodeHooksData(ctx context.Context, req *AICodeHooksRequest, endpoint Endpoint) (*AICodeHooksResponse, error) { + ctx, span := modelTracer.Start(ctx, "aicode_hooks.send") + defer span.End() + + var resp AICodeHooksResponse + err := SendHTTPRequestJSON(HTTPRequestOptions[*AICodeHooksRequest, AICodeHooksResponse]{ + Context: ctx, + Endpoint: endpoint, + Method: http.MethodPost, + Path: "/api/v1/cc/aicode-hooks", + Payload: req, + Response: &resp, + Timeout: 30 * time.Second, + }) + + if err != nil { + return nil, err + } + + return &resp, nil +} diff --git a/model/config.go b/model/config.go index fb90d57..0828459 100644 --- a/model/config.go +++ b/model/config.go @@ -162,6 +162,9 @@ func mergeConfig(base, local *ShellTimeConfig) { if local.AICodeOtel != nil { base.AICodeOtel = local.AICodeOtel } + if local.AICodeHooks != nil { + base.AICodeHooks = local.AICodeHooks + } if local.LogCleanup != nil { base.LogCleanup = local.LogCleanup } @@ -278,6 +281,17 @@ func (cs *configService) ReadConfigFile(ctx context.Context, opts ...ReadConfigO if config.AICodeOtel != nil && config.AICodeOtel.Debug != nil && *config.AICodeOtel.Debug { config.AICodeOtel.Debug = &truthy } + + // Initialize AICodeHooks config with defaults if enabled + if config.AICodeHooks != nil { + if config.AICodeHooks.BatchSize == 0 { + config.AICodeHooks.BatchSize = 50 + } + if config.AICodeHooks.BatchIntervalSeconds == 0 { + config.AICodeHooks.BatchIntervalSeconds = 5 + } + } + if config.SocketPath == "" { config.SocketPath = DefaultSocketPath } diff --git a/model/types.go b/model/types.go index 1940bf6..a9b5fd7 100644 --- a/model/types.go +++ b/model/types.go @@ -33,6 +33,14 @@ type AICodeOtel struct { Debug *bool `toml:"debug" yaml:"debug" json:"debug"` // write raw JSON to debug files } +// AICodeHooks configuration for hooks-based AI CLI tracking (Claude Code, Codex, Cursor) +type AICodeHooks struct { + Enabled *bool `toml:"enabled" yaml:"enabled" json:"enabled"` + BatchSize int `toml:"batchSize,omitempty" yaml:"batchSize,omitempty" json:"batchSize,omitempty"` + BatchIntervalSeconds int `toml:"batchIntervalSeconds,omitempty" yaml:"batchIntervalSeconds,omitempty" json:"batchIntervalSeconds,omitempty"` + Debug *bool `toml:"debug" yaml:"debug" json:"debug"` +} + // CodeTracking configuration for coding activity heartbeat tracking type CodeTracking struct { Enabled *bool `toml:"enabled" yaml:"enabled" json:"enabled"` @@ -88,6 +96,9 @@ type ShellTimeConfig struct { // AICodeOtel configuration for OTEL-based AI CLI tracking (Claude Code, Codex, etc.) AICodeOtel *AICodeOtel `toml:"aiCodeOtel" yaml:"aiCodeOtel" json:"aiCodeOtel"` + // AICodeHooks configuration for hooks-based AI CLI tracking (Claude Code, Codex, Cursor) + AICodeHooks *AICodeHooks `toml:"aiCodeHooks" yaml:"aiCodeHooks" json:"aiCodeHooks"` + // CodeTracking configuration for coding activity heartbeat tracking CodeTracking *CodeTracking `toml:"codeTracking" yaml:"codeTracking" json:"codeTracking"` @@ -130,6 +141,7 @@ var DefaultConfig = ShellTimeConfig{ GRPCPort: 54027, Debug: new(false), }), + AICodeHooks: nil, CodeTracking: new(CodeTracking{ Enabled: new(true), }),