diff --git a/container/internal/claude/parser/history.go b/container/internal/claude/parser/history.go index 5acb6dee2..26a3803f4 100644 --- a/container/internal/claude/parser/history.go +++ b/container/internal/claude/parser/history.go @@ -5,23 +5,26 @@ import ( "fmt" "io" "os" + "path/filepath" "strings" "github.com/vanpelt/catnip/internal/models" ) // HistoryReader reads user prompt history from Claude configuration files -// Supports both legacy ~/.claude.json and new ~/.claude/history.jsonl formats +// Supports both legacy ~/.claude.json and new /history.jsonl formats type HistoryReader struct { claudeConfigPath string historyJSONLPath string } -// NewHistoryReader creates a new history reader with paths to config files -func NewHistoryReader(homeDir string) *HistoryReader { +// NewHistoryReader creates a new history reader with paths to config files. +// claudeConfigDir should be obtained from config.Runtime.ClaudeConfigDir after +// runtime initialization (typically passed from the caller). +func NewHistoryReader(homeDir, claudeConfigDir string) *HistoryReader { return &HistoryReader{ - claudeConfigPath: homeDir + "/.claude.json", - historyJSONLPath: homeDir + "/.claude/history.jsonl", + claudeConfigPath: filepath.Join(homeDir, ".claude.json"), + historyJSONLPath: filepath.Join(claudeConfigDir, "history.jsonl"), } } diff --git a/container/internal/claude/paths/paths.go b/container/internal/claude/paths/paths.go index 6eedf29c9..b216c5e8c 100644 --- a/container/internal/claude/paths/paths.go +++ b/container/internal/claude/paths/paths.go @@ -9,6 +9,8 @@ import ( "path/filepath" "sort" "strings" + + "github.com/vanpelt/catnip/internal/config" ) // EncodePathForClaude encodes a filesystem path the way Claude does for project directories. @@ -23,15 +25,12 @@ func EncodePathForClaude(path string) string { } // GetProjectDir returns the Claude projects directory path for a given worktree/project path. -// Returns the full path to ~/.claude/projects// +// Returns the full path to /projects// +// On Linux, respects XDG_CONFIG_HOME (defaults to ~/.config/claude/projects). +// On macOS and other platforms, uses ~/.claude/projects. func GetProjectDir(worktreePath string) (string, error) { - homeDir, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("failed to get home directory: %w", err) - } - encodedPath := EncodePathForClaude(worktreePath) - return filepath.Join(homeDir, ".claude", "projects", encodedPath), nil + return filepath.Join(config.Runtime.GetClaudeProjectsDir(), encodedPath), nil } // IsValidSessionUUID checks if a string is a valid Claude session UUID. diff --git a/container/internal/cmd/hooks.go b/container/internal/cmd/hooks.go index 57fc8830a..9b6216171 100644 --- a/container/internal/cmd/hooks.go +++ b/container/internal/cmd/hooks.go @@ -12,6 +12,7 @@ import ( "time" "github.com/spf13/cobra" + "github.com/vanpelt/catnip/internal/config" "github.com/vanpelt/catnip/internal/logger" ) @@ -106,18 +107,13 @@ func runInstallHooks(cmd *cobra.Command, args []string) error { logger.Info("🔧 Installing Claude Code hooks for activity tracking...") } - // Get Claude directory - homeDir, err := os.UserHomeDir() - if err != nil { - return fmt.Errorf("failed to get home directory: %w", err) - } - - claudeDir := filepath.Join(homeDir, ".claude") + // Get Claude directory (respects XDG_CONFIG_HOME on Linux) + claudeDir := config.Runtime.ClaudeConfigDir settingsFile := filepath.Join(claudeDir, "settings.json") - // Create .claude directory if it doesn't exist + // Create Claude config directory if it doesn't exist if err := os.MkdirAll(claudeDir, 0755); err != nil { - return fmt.Errorf("failed to create .claude directory: %w", err) + return fmt.Errorf("failed to create Claude config directory: %w", err) } if verboseHooks { diff --git a/container/internal/config/runtime.go b/container/internal/config/runtime.go index 4feabd1e4..5ac1fc248 100644 --- a/container/internal/config/runtime.go +++ b/container/internal/config/runtime.go @@ -31,6 +31,7 @@ type RuntimeConfig struct { LiveDir string HomeDir string TempDir string + ClaudeConfigDir string // Claude config directory (respects XDG_CONFIG_HOME on Linux) CurrentRepo string // For native mode, the git repo we're running from SyncEnabled bool // Whether to sync settings to volume PortMonitorEnabled bool // Whether to use /proc for port monitoring @@ -53,6 +54,18 @@ func getEnvOrDefault(key, defaultValue string) string { return defaultValue } +// getClaudeConfigDir returns the Claude config directory path. +// If XDG_CONFIG_HOME is set, uses $XDG_CONFIG_HOME/claude. +// Otherwise, uses ~/.claude (the traditional Claude location). +func getClaudeConfigDir(homeDir string) string { + // Check XDG_CONFIG_HOME first (cross-platform) + if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" { + return filepath.Join(xdgConfigHome, "claude") + } + // Default to ~/.claude when XDG_CONFIG_HOME is not set + return filepath.Join(homeDir, ".claude") +} + // DetectRuntime determines the current runtime environment and returns appropriate configuration func DetectRuntime() *RuntimeConfig { mode := detectMode() @@ -78,6 +91,7 @@ func DetectRuntime() *RuntimeConfig { config.LiveDir = getEnvOrDefault("CATNIP_LIVE_DIR", "/live") config.HomeDir = getEnvOrDefault("CATNIP_HOME_DIR", "/home/catnip") config.TempDir = getEnvOrDefault("CATNIP_TEMP_DIR", "/tmp") + config.ClaudeConfigDir = getClaudeConfigDir(config.HomeDir) config.SyncEnabled = true config.PortMonitorEnabled = true @@ -90,6 +104,7 @@ func DetectRuntime() *RuntimeConfig { config.LiveDir = getEnvOrDefault("CATNIP_LIVE_DIR", "") // Will be set if running from a git repo config.HomeDir = getEnvOrDefault("CATNIP_HOME_DIR", homeDir) config.TempDir = getEnvOrDefault("CATNIP_TEMP_DIR", os.TempDir()) + config.ClaudeConfigDir = getClaudeConfigDir(config.HomeDir) config.SyncEnabled = false // No need to sync in native mode config.PortMonitorEnabled = runtime.GOOS == "linux" // Only on Linux @@ -270,3 +285,9 @@ func (rc *RuntimeConfig) IsNative() bool { func (rc *RuntimeConfig) IsContainerized() bool { return rc.Mode == DockerMode || rc.Mode == ContainerMode } + +// GetClaudeProjectsDir returns the Claude projects directory path. +// This is ClaudeConfigDir + "/projects" +func (rc *RuntimeConfig) GetClaudeProjectsDir() string { + return filepath.Join(rc.ClaudeConfigDir, "projects") +} diff --git a/container/internal/config/runtime_test.go b/container/internal/config/runtime_test.go new file mode 100644 index 000000000..d6d1e8dd0 --- /dev/null +++ b/container/internal/config/runtime_test.go @@ -0,0 +1,36 @@ +package config + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetClaudeConfigDir(t *testing.T) { + homeDir := "/home/testuser" + + t.Run("with XDG_CONFIG_HOME set uses $XDG_CONFIG_HOME/claude", func(t *testing.T) { + // t.Setenv automatically restores the original value after the test + t.Setenv("XDG_CONFIG_HOME", "/custom/config") + result := getClaudeConfigDir(homeDir) + assert.Equal(t, "/custom/config/claude", result) + }) + + t.Run("without XDG_CONFIG_HOME uses ~/.claude", func(t *testing.T) { + // t.Setenv with empty string effectively unsets for our purposes + t.Setenv("XDG_CONFIG_HOME", "") + result := getClaudeConfigDir(homeDir) + assert.Equal(t, filepath.Join(homeDir, ".claude"), result) + }) +} + +func TestGetClaudeProjectsDir(t *testing.T) { + t.Run("returns ClaudeConfigDir/projects", func(t *testing.T) { + rc := &RuntimeConfig{ + ClaudeConfigDir: "/home/user/.config/claude", + } + result := rc.GetClaudeProjectsDir() + assert.Equal(t, "/home/user/.config/claude/projects", result) + }) +} diff --git a/container/internal/git/claude_detector.go b/container/internal/git/claude_detector.go deleted file mode 100644 index 78f40c42b..000000000 --- a/container/internal/git/claude_detector.go +++ /dev/null @@ -1,231 +0,0 @@ -package git - -import ( - "bufio" - "encoding/json" - "fmt" - "os" - "os/exec" - "path/filepath" - "strconv" - "strings" - "time" - - "github.com/vanpelt/catnip/internal/claude/paths" -) - -// ClaudeSessionDetector detects and monitors Claude sessions running in a worktree -// DEPRECATED: This is a fallback mechanism that reads Claude's JSONL files to extract session information. -// It's unreliable as the JSONL format may change and summary records are not always present. -// Prefer using the title information from PTY escape sequences or the syscall title interceptor. -type ClaudeSessionDetector struct { - workDir string -} - -// NewClaudeSessionDetector creates a new Claude session detector -func NewClaudeSessionDetector(workDir string) *ClaudeSessionDetector { - return &ClaudeSessionDetector{ - workDir: workDir, - } -} - -// ClaudeSessionInfo contains information about a detected Claude session -type ClaudeSessionInfo struct { - SessionID string - Title string - PID int - StartTime time.Time - LastUpdated time.Time -} - -// DetectClaudeSession looks for active Claude sessions in the worktree -// NOTE: This is a best-effort approach and may not always return accurate information. -// It attempts to find session files and running processes, but Claude's internal structure may change. -func (d *ClaudeSessionDetector) DetectClaudeSession() (*ClaudeSessionInfo, error) { - // First, try to find the Claude session file - sessionInfo := d.findClaudeSessionFromFiles() - if sessionInfo != nil { - // Try to enrich with process information - if pid := d.findClaudeProcess(); pid > 0 { - sessionInfo.PID = pid - } - return sessionInfo, nil - } - - // If no session file, try to detect from running processes - pid := d.findClaudeProcess() - if pid > 0 { - return &ClaudeSessionInfo{ - PID: pid, - StartTime: time.Now(), // Approximate - }, nil - } - - return nil, fmt.Errorf("no Claude session detected") -} - -// findClaudeSessionFromFiles looks for Claude session files and extracts information -// Uses paths.FindBestSessionFile which validates UUIDs, checks conversation content, -// and filters out forked sessions -func (d *ClaudeSessionDetector) findClaudeSessionFromFiles() *ClaudeSessionInfo { - claudeProjectsDir := filepath.Join(d.workDir, ".claude", "projects") - - sessionFile, err := paths.FindBestSessionFile(claudeProjectsDir) - if err != nil || sessionFile == "" { - return nil - } - - // Extract session ID from filename - sessionID := strings.TrimSuffix(filepath.Base(sessionFile), ".jsonl") - - // Try to extract title from the JSONL file - title := d.extractTitleFromJSONL(sessionFile) - - fileInfo, _ := os.Stat(sessionFile) - return &ClaudeSessionInfo{ - SessionID: sessionID, - Title: title, - StartTime: fileInfo.ModTime(), // Approximate start time - LastUpdated: fileInfo.ModTime(), - } -} - -// extractTitleFromJSONL reads a Claude JSONL file and extracts the session title from summary records -// WARNING: This is unreliable as Claude doesn't always write summary records and the format may change. -// The "summary" field in JSONL is not guaranteed to contain the actual session title. -func (d *ClaudeSessionDetector) extractTitleFromJSONL(filePath string) string { - file, err := os.Open(filePath) - if err != nil { - return "" - } - defer file.Close() - - var lastTitle string - scanner := bufio.NewScanner(file) - - // Read through the JSONL file to find summary records - for scanner.Scan() { - line := scanner.Text() - if line == "" { - continue - } - - // Parse the JSON line - var event map[string]interface{} - if err := json.Unmarshal([]byte(line), &event); err != nil { - continue - } - - // Look for summary records which contain the session title - if eventType, ok := event["type"].(string); ok && eventType == "summary" { - if summary, ok := event["summary"].(string); ok && summary != "" { - lastTitle = summary - // Continue reading to get the most recent summary if there are multiple - } - } - } - - return lastTitle -} - -// findClaudeProcess looks for Claude processes running with the worktree as working directory -func (d *ClaudeSessionDetector) findClaudeProcess() int { - // Try using ps to find claude processes - cmd := exec.Command("ps", "aux") - output, err := cmd.Output() - if err != nil { - return 0 - } - - lines := strings.Split(string(output), "\n") - for _, line := range lines { - // Look for lines containing "claude" command - if !strings.Contains(line, "claude") || strings.Contains(line, "ps aux") { - continue - } - - // Parse the ps output to get PID - fields := strings.Fields(line) - if len(fields) < 2 { - continue - } - - pid, err := strconv.Atoi(fields[1]) - if err != nil { - continue - } - - // Try to verify this process is in our worktree - if d.isProcessInWorktree(pid) { - return pid - } - } - - return 0 -} - -// isProcessInWorktree checks if a process is running in our worktree -func (d *ClaudeSessionDetector) isProcessInWorktree(pid int) bool { - // Try to read the process's current working directory - // On Linux: /proc/[pid]/cwd - // On macOS: We need to use lsof or similar - - // Try lsof approach (works on both Linux and macOS) - cmd := exec.Command("lsof", "-p", strconv.Itoa(pid), "-Fn") - output, err := cmd.Output() - if err != nil { - return false - } - - // Parse lsof output to find cwd - lines := strings.Split(string(output), "\n") - for i, line := range lines { - if strings.HasPrefix(line, "fcwd") && i+1 < len(lines) { - // Next line should have the path - pathLine := lines[i+1] - if strings.HasPrefix(pathLine, "n") { - path := strings.TrimPrefix(pathLine, "n") - // Check if this path is within our worktree - if strings.HasPrefix(path, d.workDir) { - return true - } - } - } - } - - return false -} - -// MonitorTitleChanges monitors a Claude session for new summary records (titles) -func (d *ClaudeSessionDetector) MonitorTitleChanges(sessionID string, titleChangedFunc func(string)) error { - if sessionID == "" { - return fmt.Errorf("session ID is required") - } - - sessionFile := filepath.Join(d.workDir, ".claude", "projects", sessionID+".jsonl") - - // Start by getting the current title - lastTitle := d.extractTitleFromJSONL(sessionFile) - - // Monitor for file changes using polling - ticker := time.NewTicker(2 * time.Second) - defer ticker.Stop() - - for range ticker.C { - // Check if file still exists (session might have ended) - if _, err := os.Stat(sessionFile); os.IsNotExist(err) { - return fmt.Errorf("session file no longer exists") - } - - // Re-extract title from the file - currentTitle := d.extractTitleFromJSONL(sessionFile) - if currentTitle != "" && currentTitle != lastTitle { - lastTitle = currentTitle - if titleChangedFunc != nil { - titleChangedFunc(currentTitle) - } - } - } - - return nil -} diff --git a/container/internal/handlers/pty.go b/container/internal/handlers/pty.go index f8e819f38..235e8b96c 100644 --- a/container/internal/handlers/pty.go +++ b/container/internal/handlers/pty.go @@ -2246,7 +2246,7 @@ func getClaudeSessionTimeout() time.Duration { return 120 * time.Second // Default: Give Claude 2 minutes to create session file } -// monitorClaudeSession monitors .claude/projects directory for new session files +// monitorClaudeSession monitors Claude projects directory for new session files func (h *PTYHandler) monitorClaudeSession(session *Session) { logger.Debugf("👀 Starting Claude session monitoring for %s in %s", session.ID, session.WorkDir) @@ -2256,7 +2256,8 @@ func (h *PTYHandler) monitorClaudeSession(session *Session) { startTime := time.Now() timeout := getClaudeSessionTimeout() - claudeProjectsDir := filepath.Join(session.WorkDir, ".claude", "projects") + // Construct the Claude projects directory path for this workspace + claudeProjectsDir := filepath.Join(config.Runtime.GetClaudeProjectsDir(), paths.EncodePathForClaude(session.WorkDir)) for range ticker.C { if time.Since(startTime) > timeout { @@ -2284,16 +2285,14 @@ func (h *PTYHandler) monitorClaudeSession(session *Session) { // getClaudeSessionLogModTime returns the modification time of the most recently modified Claude session log func (h *PTYHandler) getClaudeSessionLogModTime(workDir string) time.Time { - homeDir := config.Runtime.HomeDir - // Transform workDir path to Claude projects directory format transformedPath := strings.ReplaceAll(workDir, "/", "-") transformedPath = strings.TrimPrefix(transformedPath, "-") transformedPath = "-" + transformedPath // Add back the leading dash - claudeProjectsDir := filepath.Join(homeDir, ".claude", "projects", transformedPath) + claudeProjectsDir := filepath.Join(config.Runtime.GetClaudeProjectsDir(), transformedPath) - // Check if .claude/projects directory exists + // Check if projects directory exists if _, err := os.Stat(claudeProjectsDir); os.IsNotExist(err) { return time.Time{} } @@ -2611,10 +2610,10 @@ func (h *PTYHandler) detectClaudeErrorsFromJSONL(session *Session) []string { } // Construct path to Claude JSONL files - homeDir := config.Runtime.HomeDir transformedPath := strings.ReplaceAll(session.WorkDir, "/", "-") transformedPath = strings.TrimPrefix(transformedPath, "-") - projectsDir := filepath.Join(homeDir, ".claude", "projects", "-"+transformedPath) + transformedPath = "-" + transformedPath // Add back the leading dash + projectsDir := filepath.Join(config.Runtime.GetClaudeProjectsDir(), transformedPath) // Find the most recent JSONL file entries, err := os.ReadDir(projectsDir) diff --git a/container/internal/models/settings.go b/container/internal/models/settings.go index c969629db..7bbd839de 100644 --- a/container/internal/models/settings.go +++ b/container/internal/models/settings.go @@ -33,6 +33,7 @@ type Settings struct { done chan bool volumePath string homePath string + claudeConfigDir string // Claude config directory (respects XDG_CONFIG_HOME on Linux) lastModTimes map[string]time.Time debounceMap map[string]*time.Timer // For debouncing file changes lastDirSync map[string]time.Time // For tracking directory sync times @@ -45,6 +46,7 @@ func NewSettings() *Settings { return &Settings{ volumePath: config.Runtime.VolumeDir, homePath: config.Runtime.HomeDir, + claudeConfigDir: config.Runtime.ClaudeConfigDir, lastModTimes: make(map[string]time.Time), debounceMap: make(map[string]*time.Timer), lastDirSync: make(map[string]time.Time), @@ -133,9 +135,9 @@ func (s *Settings) restoreFromVolumeOnBoot() { filename string destPath string }{ - {volumeClaudeNestedDir, ".credentials.json", filepath.Join(s.homePath, ".claude", ".credentials.json")}, + {volumeClaudeNestedDir, ".credentials.json", filepath.Join(s.claudeConfigDir, ".credentials.json")}, {volumeClaudeDir, "claude.json", filepath.Join(s.homePath, ".claude.json")}, - {volumeClaudeNestedDir, "history.jsonl", filepath.Join(s.homePath, ".claude", "history.jsonl")}, + {volumeClaudeNestedDir, "history.jsonl", filepath.Join(s.claudeConfigDir, "history.jsonl")}, {volumeGitHubDir, "config.yml", filepath.Join(s.homePath, ".config", "gh", "config.yml")}, {volumeGitHubDir, "hosts.yml", filepath.Join(s.homePath, ".config", "gh", "hosts.yml")}, } @@ -199,7 +201,7 @@ func (s *Settings) restoreFromVolumeOnBoot() { // restoreIDEDirectory copies the IDE directory from volume to home if it exists func (s *Settings) restoreIDEDirectory() { volumeIDEDir := filepath.Join(s.volumePath, ".claude", "ide") - homeIDEDir := filepath.Join(s.homePath, ".claude", "ide") + homeIDEDir := filepath.Join(s.claudeConfigDir, "ide") // Check if volume IDE directory exists if _, err := os.Stat(volumeIDEDir); os.IsNotExist(err) { @@ -224,7 +226,7 @@ func (s *Settings) restoreIDEDirectory() { // restoreClaudeProjectsDirectory copies the Claude projects directory from volume to home if it exists func (s *Settings) restoreClaudeProjectsDirectory() { volumeProjectsDir := filepath.Join(s.volumePath, ".claude", ".claude", "projects") - homeProjectsDir := filepath.Join(s.homePath, ".claude", "projects") + homeProjectsDir := filepath.Join(s.claudeConfigDir, "projects") // Check if volume projects directory exists if _, err := os.Stat(volumeProjectsDir); os.IsNotExist(err) { @@ -381,7 +383,7 @@ func (s *Settings) watchForChanges() { // checkAndSyncClaudeProjects monitors the Claude projects directory for changes and syncs .jsonl files func (s *Settings) checkAndSyncClaudeProjects() { - homeProjectsDir := filepath.Join(s.homePath, ".claude", "projects") + homeProjectsDir := filepath.Join(s.claudeConfigDir, "projects") volumeProjectsDir := filepath.Join(s.volumePath, ".claude", ".claude", "projects") // Check if home projects directory exists @@ -500,9 +502,9 @@ func (s *Settings) checkAndSyncFiles() { destName string sensitive bool // True for files that need extra care (like ~/.claude.json) }{ - {filepath.Join(s.homePath, ".claude", ".credentials.json"), filepath.Join(s.volumePath, ".claude", ".claude"), ".credentials.json", true}, + {filepath.Join(s.claudeConfigDir, ".credentials.json"), filepath.Join(s.volumePath, ".claude", ".claude"), ".credentials.json", true}, {filepath.Join(s.homePath, ".claude.json"), filepath.Join(s.volumePath, ".claude"), "claude.json", true}, - {filepath.Join(s.homePath, ".claude", "history.jsonl"), filepath.Join(s.volumePath, ".claude", ".claude"), "history.jsonl", false}, + {filepath.Join(s.claudeConfigDir, "history.jsonl"), filepath.Join(s.volumePath, ".claude", ".claude"), "history.jsonl", false}, {filepath.Join(s.homePath, ".config", "gh", "config.yml"), filepath.Join(s.volumePath, ".github"), "config.yml", false}, {filepath.Join(s.homePath, ".config", "gh", "hosts.yml"), filepath.Join(s.volumePath, ".github"), "hosts.yml", false}, } @@ -730,7 +732,7 @@ func (s *Settings) copyFile(src, dst string) error { // ValidateSettings checks if the Claude settings files contain valid JSON func (s *Settings) ValidateSettings() error { files := []string{ - filepath.Join(s.homePath, ".claude", ".credentials.json"), + filepath.Join(s.claudeConfigDir, ".credentials.json"), filepath.Join(s.homePath, ".claude.json"), } diff --git a/container/internal/services/claude.go b/container/internal/services/claude.go index 682ce4ff1..c17824d67 100644 --- a/container/internal/services/claude.go +++ b/container/internal/services/claude.go @@ -68,9 +68,10 @@ func NewClaudeService() *ClaudeService { // Use runtime-appropriate directories homeDir := config.Runtime.HomeDir volumeDir := config.Runtime.VolumeDir + claudeConfigDir := config.Runtime.ClaudeConfigDir return &ClaudeService{ claudeConfigPath: filepath.Join(homeDir, ".claude.json"), - claudeProjectsDir: filepath.Join(homeDir, ".claude", "projects"), + claudeProjectsDir: filepath.Join(claudeConfigDir, "projects"), volumeProjectsDir: filepath.Join(volumeDir, ".claude", ".claude", "projects"), settingsPath: filepath.Join(volumeDir, "settings.json"), subprocessWrapper: NewClaudeSubprocessWrapper(), @@ -89,9 +90,10 @@ func NewClaudeServiceWithWrapper(wrapper ClaudeSubprocessInterface) *ClaudeServi // Use runtime-appropriate directories homeDir := config.Runtime.HomeDir volumeDir := config.Runtime.VolumeDir + claudeConfigDir := config.Runtime.ClaudeConfigDir return &ClaudeService{ claudeConfigPath: filepath.Join(homeDir, ".claude.json"), - claudeProjectsDir: filepath.Join(homeDir, ".claude", "projects"), + claudeProjectsDir: filepath.Join(claudeConfigDir, "projects"), volumeProjectsDir: filepath.Join(volumeDir, ".claude", ".claude", "projects"), settingsPath: filepath.Join(volumeDir, "settings.json"), subprocessWrapper: wrapper, @@ -1135,8 +1137,8 @@ func (s *ClaudeService) GetClaudeSettings() (*models.ClaudeSettings, error) { return nil, fmt.Errorf("failed to read claude config file: %w", err) } - var config map[string]interface{} - if err := json.Unmarshal(data, &config); err != nil { + var configData map[string]interface{} + if err := json.Unmarshal(data, &configData); err != nil { return nil, fmt.Errorf("failed to parse claude config: %w", err) } @@ -1150,7 +1152,7 @@ func (s *ClaudeService) GetClaudeSettings() (*models.ClaudeSettings, error) { } // Extract theme (default to "dark" if not set) - if theme, exists := config["theme"]; exists { + if theme, exists := configData["theme"]; exists { if themeStr, ok := theme.(string); ok { settings.Theme = themeStr } @@ -1158,27 +1160,27 @@ func (s *ClaudeService) GetClaudeSettings() (*models.ClaudeSettings, error) { // Check authentication status based on credentials file existence // Don't rely on userID in config - check if credentials actually exist - credentialsPath := filepath.Join(os.Getenv("HOME"), ".claude", ".credentials.json") + credentialsPath := filepath.Join(config.Runtime.ClaudeConfigDir, ".credentials.json") if _, err := os.Stat(credentialsPath); err == nil { settings.IsAuthenticated = true } // Extract version from lastReleaseNotesSeen - if lastRelease, exists := config["lastReleaseNotesSeen"]; exists { + if lastRelease, exists := configData["lastReleaseNotesSeen"]; exists { if lastReleaseStr, ok := lastRelease.(string); ok && lastReleaseStr != "" { settings.Version = lastReleaseStr } } // Extract onboarding status - if onboarding, exists := config["hasCompletedOnboarding"]; exists { + if onboarding, exists := configData["hasCompletedOnboarding"]; exists { if onboardingBool, ok := onboarding.(bool); ok { settings.HasCompletedOnboarding = onboardingBool } } // Extract startup count - if startups, exists := config["numStartups"]; exists { + if startups, exists := configData["numStartups"]; exists { if startupsFloat, ok := startups.(float64); ok { settings.NumStartups = int(startupsFloat) } @@ -1940,7 +1942,7 @@ func (m *ClaudePTYManager) waitForSessionFile() error { // Calculate expected project directory projectDirName := WorktreePathToProjectDir(m.workingDir) - m.projectDir = filepath.Join(config.Runtime.HomeDir, ".claude", "projects", projectDirName) + m.projectDir = filepath.Join(config.Runtime.GetClaudeProjectsDir(), projectDirName) for { select { diff --git a/container/internal/services/claude_monitor.go b/container/internal/services/claude_monitor.go index 3a7f20b07..5a712506c 100644 --- a/container/internal/services/claude_monitor.go +++ b/container/internal/services/claude_monitor.go @@ -1163,7 +1163,7 @@ func (s *ClaudeMonitorService) startWorktreeTodoMonitor(worktreeID, worktreePath // findProjectDirectory finds the Claude project directory for a given project name func (s *ClaudeMonitorService) findProjectDirectory(projectDirName string) string { - claudeProjectsDir := filepath.Join(config.Runtime.HomeDir, ".claude", "projects") + claudeProjectsDir := config.Runtime.GetClaudeProjectsDir() projectPath := filepath.Join(claudeProjectsDir, projectDirName) if stat, err := os.Stat(projectPath); err == nil && stat.IsDir() { diff --git a/container/internal/services/claude_parser.go b/container/internal/services/claude_parser.go index c9d9d7f5c..92bf8ce0b 100644 --- a/container/internal/services/claude_parser.go +++ b/container/internal/services/claude_parser.go @@ -34,9 +34,10 @@ type parserInstance struct { // NewParserService creates a new parser service func NewParserService() *ParserService { homeDir := config.Runtime.HomeDir + claudeConfigDir := config.Runtime.ClaudeConfigDir return &ParserService{ parsers: make(map[string]*parserInstance), - historyReader: parser.NewHistoryReader(homeDir), + historyReader: parser.NewHistoryReader(homeDir, claudeConfigDir), maxParsers: 100, // Reasonable default: support 100 concurrent worktrees stopCh: make(chan struct{}), } @@ -170,9 +171,8 @@ func (s *ParserService) RemoveParser(worktreePath string) { func (s *ParserService) findSessionFile(worktreePath string) (string, error) { projectDirName := WorktreePathToProjectDir(worktreePath) - // Check local directory first - homeDir := config.Runtime.HomeDir - localDir := filepath.Join(homeDir, ".claude", "projects", projectDirName) + // Check local directory first (respects XDG_CONFIG_HOME on Linux) + localDir := filepath.Join(config.Runtime.GetClaudeProjectsDir(), projectDirName) if sessionFile, err := paths.FindBestSessionFile(localDir); err == nil && sessionFile != "" { return sessionFile, nil diff --git a/container/internal/services/claude_parser_test.go b/container/internal/services/claude_parser_test.go index aed6bf8ca..9861ef8f8 100644 --- a/container/internal/services/claude_parser_test.go +++ b/container/internal/services/claude_parser_test.go @@ -56,7 +56,7 @@ func setupTestSession(t *testing.T, worktreePath, testdataFile string) { t.Helper() projectDirName := WorktreePathToProjectDir(worktreePath) - projectDir := filepath.Join(config.Runtime.HomeDir, ".claude", "projects", projectDirName) + projectDir := filepath.Join(config.Runtime.GetClaudeProjectsDir(), projectDirName) err := os.MkdirAll(projectDir, 0755) require.NoError(t, err) diff --git a/container/internal/services/session.go b/container/internal/services/session.go index d97751576..0e03e41e1 100644 --- a/container/internal/services/session.go +++ b/container/internal/services/session.go @@ -140,15 +140,14 @@ func (s *SessionService) LoadSessionState(sessionID string) (*SessionState, erro // FindSessionByDirectory finds an active Claude session in the given directory func (s *SessionService) FindSessionByDirectory(workDir string) (*SessionState, error) { - // First, try to find the newest session file directly from .claude/projects - // Claude stores projects in ~/.claude/projects/{transformed-path}/ + // First, try to find the newest session file directly from the Claude projects directory + // Claude stores projects in /projects/{transformed-path}/ // where the path is transformed: /workspace/catnip/buddy -> -workspace-catnip-buddy - homeDir := config.Runtime.HomeDir transformedPath := strings.ReplaceAll(workDir, "/", "-") transformedPath = strings.TrimPrefix(transformedPath, "-") transformedPath = "-" + transformedPath // Add back the leading dash - claudeProjectsDir := filepath.Join(homeDir, ".claude", "projects", transformedPath) + claudeProjectsDir := filepath.Join(config.Runtime.GetClaudeProjectsDir(), transformedPath) if newestSessionID := s.findNewestClaudeSessionFile(claudeProjectsDir); newestSessionID != "" { // Create a minimal state for the newest session found return &SessionState{ @@ -253,14 +252,13 @@ func (s *SessionService) getLastClaudeActivityTime(workDir string) time.Time { } // Fallback to file modification time method - homeDir := config.Runtime.HomeDir transformedPath := strings.ReplaceAll(workDir, "/", "-") transformedPath = strings.TrimPrefix(transformedPath, "-") transformedPath = "-" + transformedPath // Add back the leading dash - claudeProjectsDir := filepath.Join(homeDir, ".claude", "projects", transformedPath) + claudeProjectsDir := filepath.Join(config.Runtime.GetClaudeProjectsDir(), transformedPath) - // Check if .claude/projects directory exists + // Check if projects directory exists if _, err := os.Stat(claudeProjectsDir); os.IsNotExist(err) { return time.Time{} // Zero time if directory doesn't exist } @@ -802,9 +800,9 @@ func (s *SessionService) hasRunningPTY(workspaceDir, claudeSessionUUID string) b } // Method 2: Check for active claude sessions by looking for recent activity - // in the .claude/projects directory + // in the Claude projects directory if claudeSessionUUID != "" { - claudeProjectsDir := filepath.Join(workspaceDir, ".claude", "projects") + claudeProjectsDir := config.Runtime.GetClaudeProjectsDir() sessionFile := filepath.Join(claudeProjectsDir, claudeSessionUUID+".jsonl") if info, err := os.Stat(sessionFile); err == nil { diff --git a/container/internal/services/session_test.go b/container/internal/services/session_test.go index 6ad3d6449..989e4916b 100644 --- a/container/internal/services/session_test.go +++ b/container/internal/services/session_test.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/vanpelt/catnip/internal/config" ) func TestSessionService(t *testing.T) { @@ -241,43 +242,51 @@ func TestSessionServiceDirectory(t *testing.T) { transformedPath = strings.TrimPrefix(transformedPath, "-") transformedPath = "-" + transformedPath - // Create Claude directory in the expected home location (using tempDir as fake home) - homeDir := tempDir - claudeDir := filepath.Join(homeDir, ".claude", "projects", transformedPath) + // Create Claude directory in the expected Claude config location + claudeDir := filepath.Join(config.Runtime.GetClaudeProjectsDir(), transformedPath) require.NoError(t, os.MkdirAll(claudeDir, 0755)) - // Create a fake Claude session file + // Create a fake Claude session file with conversation content + // The file must contain {"type":"user"} or {"type":"assistant"} to be detected as a valid session sessionFile := filepath.Join(claudeDir, "12345678-1234-1234-1234-123456789abc.jsonl") - require.NoError(t, os.WriteFile(sessionFile, []byte("{}"), 0644)) + sessionContent := `{"type":"user","message":"test"}` + "\n" + require.NoError(t, os.WriteFile(sessionFile, []byte(sessionContent), 0644)) - // Update the service to use our tempDir as home directory for testing - originalHomeDir := "/home/catnip" - // We need to temporarily modify the method to use tempDir as home - // Since we can't easily mock this, let's create a fallback test using the persisted session approach + // Find session by directory - should find the Claude session file directly + // When found directly, the function returns ID "detected" with the Claude session UUID + foundSession, err := service.FindSessionByDirectory(testWorkspace) + require.NoError(t, err) + require.NotNil(t, foundSession) + assert.Equal(t, "detected", foundSession.ID) + assert.Equal(t, testWorkspace, foundSession.WorkingDirectory) + assert.Equal(t, "claude", foundSession.Agent) + assert.Equal(t, "12345678-1234-1234-1234-123456789abc", foundSession.ClaudeSessionID) + }) - // Instead, test the fallback mechanism by creating a persisted session state + t.Run("FindSessionByDirectory_Fallback", func(t *testing.T) { + // Test the fallback mechanism when Claude session file doesn't exist + testWorkspaceFallback := filepath.Join(tempDir, "test-workspace-fallback") + + // Create a persisted session state (without corresponding Claude files) sessionState := &SessionState{ - ID: "test-session", - WorkingDirectory: testWorkspace, + ID: "fallback-session", + WorkingDirectory: testWorkspaceFallback, Agent: "claude", - ClaudeSessionID: "12345678-1234-1234-1234-123456789abc", + ClaudeSessionID: "87654321-4321-4321-4321-cba987654321", CreatedAt: time.Now(), LastAccess: time.Now(), } require.NoError(t, service.SaveSessionState(sessionState)) - // Find session by directory (should find via fallback mechanism) - foundSession, err := service.FindSessionByDirectory(testWorkspace) + // Find session by directory (should find via fallback mechanism since no Claude file exists) + foundSession, err := service.FindSessionByDirectory(testWorkspaceFallback) require.NoError(t, err) require.NotNil(t, foundSession) - assert.Equal(t, "test-session", foundSession.ID) - assert.Equal(t, testWorkspace, foundSession.WorkingDirectory) + assert.Equal(t, "fallback-session", foundSession.ID) + assert.Equal(t, testWorkspaceFallback, foundSession.WorkingDirectory) assert.Equal(t, "claude", foundSession.Agent) - assert.Equal(t, "12345678-1234-1234-1234-123456789abc", foundSession.ClaudeSessionID) - - // Clean up - _ = originalHomeDir // Prevent unused variable warning + assert.Equal(t, "87654321-4321-4321-4321-cba987654321", foundSession.ClaudeSessionID) }) t.Run("FindNewestClaudeSessionFile", func(t *testing.T) {