Skip to main content

devboy_core/agents/
claude.rs

1//! Claude Code detector.
2//!
3//! On-disk traces:
4//! - Install marker: `~/.claude/` exists OR `claude` in PATH.
5//! - Sessions: `~/.claude/projects/<encoded-cwd>/<uuid>.jsonl` — one file per session.
6//! - last_used: max mtime across all session files.
7
8use std::path::Path;
9
10use super::fs_util::{max_mtime_in, walk_files};
11use super::{AgentDetector, AgentSnapshot, InstallStatus};
12
13const ID: &str = "claude";
14const DISPLAY_NAME: &str = "Claude Code";
15const MAX_FILES: usize = 20_000;
16
17pub struct ClaudeDetector;
18
19impl AgentDetector for ClaudeDetector {
20    fn id(&self) -> &'static str {
21        ID
22    }
23    fn display_name(&self) -> &'static str {
24        DISPLAY_NAME
25    }
26
27    fn detect(&self, home: &Path) -> AgentSnapshot {
28        let claude_dir = home.join(".claude");
29        let projects_dir = claude_dir.join("projects");
30        let paths_checked = vec![claude_dir.clone(), projects_dir.clone()];
31
32        let binary_present = which::which("claude").is_ok();
33        let dir_present = claude_dir.is_dir();
34
35        let status = match (dir_present, binary_present) {
36            (true, _) => InstallStatus::Yes,
37            (false, true) => InstallStatus::Yes,
38            (false, false) => InstallStatus::No,
39        };
40
41        if status == InstallStatus::No {
42            return AgentSnapshot {
43                id: ID,
44                display_name: DISPLAY_NAME,
45                status,
46                sessions: None,
47                last_used: None,
48                score: 0.0,
49                paths_checked,
50            };
51        }
52
53        let session_files = walk_files(
54            &projects_dir,
55            |p| p.extension().is_some_and(|e| e == "jsonl"),
56            MAX_FILES,
57        );
58        let sessions = (!session_files.is_empty()).then_some(session_files.len() as u64);
59        let last_used = walk_max_mtime(&projects_dir);
60
61        AgentSnapshot {
62            id: ID,
63            display_name: DISPLAY_NAME,
64            status,
65            sessions,
66            last_used,
67            score: 0.0,
68            paths_checked,
69        }
70    }
71}
72
73/// Recursively walk the projects/ tree taking the max mtime of any .jsonl.
74fn walk_max_mtime(projects_dir: &Path) -> Option<chrono::DateTime<chrono::Utc>> {
75    if !projects_dir.is_dir() {
76        return None;
77    }
78    let entries = std::fs::read_dir(projects_dir).ok()?;
79    let mut best = None;
80    for entry in entries.flatten() {
81        let path = entry.path();
82        if path.is_dir() {
83            let inner = max_mtime_in(&path, |p| p.extension().is_some_and(|e| e == "jsonl"));
84            if let Some(t) = inner {
85                best = Some(best.map_or(t, |b: chrono::DateTime<chrono::Utc>| b.max(t)));
86            }
87        }
88    }
89    best
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95    use std::fs;
96    use tempfile::tempdir;
97
98    #[test]
99    fn no_claude_dir_means_not_installed() {
100        let home = tempdir().unwrap();
101        let snap = ClaudeDetector.detect(home.path());
102        // Note: if `claude` is in PATH on the dev machine running tests,
103        // status flips to Yes via binary lookup. Guard accordingly.
104        if which::which("claude").is_err() {
105            assert_eq!(snap.status, InstallStatus::No);
106            assert!(snap.sessions.is_none());
107        }
108    }
109
110    #[test]
111    fn counts_session_files_under_projects() {
112        let home = tempdir().unwrap();
113        let projects = home.path().join(".claude/projects/-Users-x-foo");
114        fs::create_dir_all(&projects).unwrap();
115        for i in 0..3 {
116            fs::write(projects.join(format!("session-{i}.jsonl")), b"{}\n").unwrap();
117        }
118        // Subdir for another project
119        let other = home.path().join(".claude/projects/-Users-x-bar");
120        fs::create_dir_all(&other).unwrap();
121        fs::write(other.join("only.jsonl"), b"{}\n").unwrap();
122
123        let snap = ClaudeDetector.detect(home.path());
124        assert_eq!(snap.status, InstallStatus::Yes);
125        assert_eq!(snap.sessions, Some(4));
126        assert!(snap.last_used.is_some());
127    }
128
129    #[test]
130    fn claude_dir_without_projects_subdir_still_reports_install() {
131        let home = tempdir().unwrap();
132        fs::create_dir_all(home.path().join(".claude")).unwrap();
133        let snap = ClaudeDetector.detect(home.path());
134        assert_eq!(snap.status, InstallStatus::Yes);
135        assert!(snap.sessions.is_none());
136        assert!(snap.last_used.is_none());
137    }
138
139    #[test]
140    fn empty_projects_dir_yields_no_sessions() {
141        let home = tempdir().unwrap();
142        fs::create_dir_all(home.path().join(".claude/projects")).unwrap();
143        let snap = ClaudeDetector.detect(home.path());
144        assert_eq!(snap.status, InstallStatus::Yes);
145        assert!(snap.sessions.is_none());
146    }
147
148    #[test]
149    fn ignores_non_jsonl_files_inside_project_dir() {
150        let home = tempdir().unwrap();
151        let proj = home.path().join(".claude/projects/-x-y");
152        fs::create_dir_all(&proj).unwrap();
153        fs::write(proj.join("session.jsonl"), b"{}\n").unwrap();
154        fs::write(proj.join("README.md"), b"x").unwrap();
155        fs::write(proj.join("data.json"), b"{}").unwrap();
156
157        let snap = ClaudeDetector.detect(home.path());
158        assert_eq!(snap.sessions, Some(1));
159    }
160}