devboy_core/agents/
claude.rs1use 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
73fn 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 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 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}