1#![forbid(unsafe_code)]
13
14#[cfg(feature = "connectors")]
15pub mod connectors;
16pub mod types;
17
18pub use types::DetectionResult;
20#[cfg(feature = "connectors")]
21pub use types::{
22 LOCAL_SOURCE_ID,
24 NormalizedConversation,
25 NormalizedMessage,
26 NormalizedSnippet,
27 Origin,
28 PathMapping,
29 Platform,
30 SourceKind,
31 reindex_messages,
32};
33#[cfg(feature = "chatgpt")]
35pub use connectors::chatgpt::ChatGptConnector;
36#[cfg(feature = "cursor")]
37pub use connectors::cursor::CursorConnector;
38#[cfg(feature = "opencode")]
39pub use connectors::opencode::OpenCodeConnector;
40#[cfg(feature = "connectors")]
41pub use connectors::token_extraction::{ExtractedTokenUsage, ModelInfo, TokenDataSource};
42#[cfg(feature = "connectors")]
43pub use connectors::{
44 Connector, PathTrie, ScanContext, ScanRoot, WorkspaceCache, aider::AiderConnector,
45 amp::AmpConnector, claude_code::ClaudeCodeConnector, clawdbot::ClawdbotConnector,
46 cline::ClineConnector, codex::CodexConnector, copilot::CopilotConnector,
47 copilot_cli::CopilotCliConnector, estimate_tokens_from_content, extract_claude_code_tokens,
48 extract_codex_tokens, extract_tokens_for_agent, factory::FactoryConnector, file_modified_since,
49 flatten_content, franken_detection_for_connector, gemini::GeminiConnector,
50 get_connector_factories, kimi::KimiConnector, normalize_model, openclaw::OpenClawConnector,
51 parse_timestamp, pi_agent::PiAgentConnector, qwen::QwenConnector, token_extraction,
52 vibe::VibeConnector,
53};
54
55use serde::{Deserialize, Serialize};
56use std::collections::{HashMap, HashSet};
57use std::path::PathBuf;
58
59#[derive(Debug, Clone, Default)]
60pub struct AgentDetectOptions {
61 pub only_connectors: Option<Vec<String>>,
65
66 pub include_undetected: bool,
68
69 pub root_overrides: Vec<AgentDetectRootOverride>,
71}
72
73#[derive(Debug, Clone)]
74pub struct AgentDetectRootOverride {
75 pub slug: String,
76 pub root: PathBuf,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
80pub struct InstalledAgentDetectionSummary {
81 pub detected_count: usize,
82 pub total_count: usize,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
86pub struct InstalledAgentDetectionEntry {
87 pub slug: String,
89 pub detected: bool,
90 pub evidence: Vec<String>,
91 pub root_paths: Vec<String>,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
95pub struct InstalledAgentDetectionReport {
96 pub format_version: u32,
97 pub generated_at: String,
98 pub installed_agents: Vec<InstalledAgentDetectionEntry>,
99 pub summary: InstalledAgentDetectionSummary,
100}
101
102#[derive(Debug, thiserror::Error)]
103pub enum AgentDetectError {
104 #[error("agent detection is disabled (compile with feature `agent-detect`)")]
105 FeatureDisabled,
106
107 #[error("unknown connector(s): {connectors:?}")]
108 UnknownConnectors { connectors: Vec<String> },
109}
110
111const KNOWN_CONNECTORS: &[&str] = &[
112 "aider",
113 "amp",
114 "chatgpt",
115 "claude",
116 "clawdbot",
117 "cline",
118 "codex",
119 "continue",
120 "copilot_cli",
121 "cursor",
122 "factory",
123 "gemini",
124 "github-copilot",
125 "goose",
126 "kimi",
127 "opencode",
128 "openclaw",
129 "pi_agent",
130 "qwen",
131 "vibe",
132 "windsurf",
133];
134
135fn canonical_connector_slug(slug: &str) -> Option<&'static str> {
136 match slug {
137 "aider" | "aider-cli" => Some("aider"),
138 "amp" | "amp-cli" => Some("amp"),
139 "chatgpt" | "chat-gpt" | "chatgpt-desktop" => Some("chatgpt"),
140 "claude" | "claude-code" => Some("claude"),
141 "clawdbot" | "clawd-bot" => Some("clawdbot"),
142 "cline" => Some("cline"),
143 "codex" | "codex-cli" => Some("codex"),
144 "continue" | "continue-dev" => Some("continue"),
145 "copilot_cli" | "copilot-cli" | "gh-copilot" => Some("copilot_cli"),
146 "cursor" => Some("cursor"),
147 "factory" | "factory-droid" => Some("factory"),
148 "gemini" | "gemini-cli" => Some("gemini"),
149 "github-copilot" | "copilot" => Some("github-copilot"),
150 "goose" | "goose-ai" => Some("goose"),
151 "kimi" | "kimi-code" | "kimi-ai" => Some("kimi"),
152 "opencode" | "open-code" => Some("opencode"),
153 "openclaw" | "open-claw" => Some("openclaw"),
154 "pi_agent" | "pi-agent" | "piagent" => Some("pi_agent"),
155 "qwen" | "qwen-code" | "qwen-cli" => Some("qwen"),
156 "vibe" | "vibe-cli" => Some("vibe"),
157 "windsurf" => Some("windsurf"),
158 _ => None,
159 }
160}
161
162fn normalize_slug(raw: &str) -> Option<String> {
163 let slug = raw.trim().to_ascii_lowercase();
164 if slug.is_empty() { None } else { Some(slug) }
165}
166
167fn canonical_or_normalized_slug(raw: &str) -> Option<String> {
168 let normalized = normalize_slug(raw)?;
169 Some(canonical_connector_slug(&normalized).map_or(normalized, std::string::ToString::to_string))
170}
171
172fn home_join(parts: &[&str]) -> Option<PathBuf> {
173 let mut path = dirs::home_dir()?;
174 for part in parts {
175 path.push(part);
176 }
177 Some(path)
178}
179
180fn cwd_join(parts: &[&str]) -> Option<PathBuf> {
181 let mut path = std::env::current_dir().ok()?;
182 for part in parts {
183 path.push(part);
184 }
185 Some(path)
186}
187
188fn env_override_roots(slug: &str) -> Option<Vec<PathBuf>> {
189 let read = |key: &str| std::env::var(key).ok().map(|v| v.trim().to_string());
190
191 match slug {
192 "aider" => {
193 let root = read("CASS_AIDER_DATA_ROOT")?;
194 if root.is_empty() {
195 return None;
196 }
197 Some(vec![PathBuf::from(root)])
198 }
199 "codex" => {
200 let root = read("CODEX_HOME")?;
201 if root.is_empty() {
202 return None;
203 }
204 Some(vec![PathBuf::from(root).join("sessions")])
205 }
206 "pi_agent" => {
207 let root = read("PI_CODING_AGENT_DIR")?;
208 if root.is_empty() {
209 return None;
210 }
211 Some(vec![PathBuf::from(root).join("sessions")])
212 }
213 _ => None,
214 }
215}
216
217#[allow(clippy::too_many_lines)]
218fn default_probe_roots(slug: &str) -> Vec<PathBuf> {
219 let mut out = Vec::new();
220 let mut push = |parts: &[&str]| {
221 if let Some(path) = home_join(parts) {
222 out.push(path);
223 }
224 };
225
226 match slug {
227 "aider" => {
228 push(&[".aider.chat.history.md"]);
229 push(&[".aider"]);
230 if let Some(cwd_marker) = cwd_join(&[".aider.chat.history.md"]) {
231 out.push(cwd_marker);
232 }
233 }
234 "amp" => {
235 push(&[".local", "share", "amp"]);
236 push(&["Library", "Application Support", "amp"]);
237 push(&["AppData", "Roaming", "amp"]);
238 push(&[
239 ".config",
240 "Code",
241 "User",
242 "globalStorage",
243 "sourcegraph.amp",
244 ]);
245 push(&[
246 "Library",
247 "Application Support",
248 "Code",
249 "User",
250 "globalStorage",
251 "sourcegraph.amp",
252 ]);
253 push(&[
254 "AppData",
255 "Roaming",
256 "Code",
257 "User",
258 "globalStorage",
259 "sourcegraph.amp",
260 ]);
261 }
262 "chatgpt" => {
263 push(&["Library", "Application Support", "com.openai.chat"]);
264 }
265 "claude" => {
266 push(&[".claude"]);
267 push(&[".config", "claude"]);
268 }
269 "clawdbot" => {
270 push(&[".clawdbot"]);
271 push(&[".clawdbot", "sessions"]);
272 }
273 "cline" => {
274 push(&[".cline"]);
275 push(&[".config", "cline"]);
276 }
277 "codex" => {
278 push(&[".codex", "sessions"]);
279 }
280 "continue" => {
281 push(&[".continue", "sessions"]);
282 push(&[".continue"]);
283 }
284 "copilot_cli" => {
285 push(&[".copilot", "session-state"]);
286 push(&[".copilot", "history-session-state"]);
287 push(&[".config", "gh-copilot"]);
288 push(&[".config", "gh", "copilot"]);
289 push(&[".local", "share", "github-copilot"]);
290 }
291 "cursor" => {
292 push(&[".cursor"]);
293 push(&[".config", "Cursor"]);
294 }
295 "factory" => {
296 push(&[".factory-droid"]);
297 push(&[".config", "factory-droid"]);
298 }
299 "gemini" => {
300 push(&[".gemini"]);
301 push(&[".config", "gemini"]);
302 }
303 "github-copilot" => {
304 push(&[".github-copilot"]);
305 push(&[".config", "github-copilot"]);
306 push(&[".copilot", "session-state"]);
307 push(&[".copilot", "history-session-state"]);
308 }
309 "goose" => {
310 push(&[".goose", "sessions"]);
311 push(&[".goose"]);
312 }
313 "kimi" => {
314 push(&[".kimi", "sessions"]);
315 push(&[".kimi"]);
316 }
317 "opencode" => {
318 push(&[".opencode"]);
319 push(&[".config", "opencode"]);
320 }
321 "openclaw" => {
322 push(&[".openclaw"]);
323 push(&[".openclaw", "agents"]);
324 }
325 "pi_agent" => {
326 push(&[".pi", "agent", "sessions"]);
327 }
328 "qwen" => {
329 push(&[".qwen", "tmp"]);
330 push(&[".qwen"]);
331 }
332 "vibe" => {
333 push(&[".vibe"]);
334 push(&[".vibe", "logs", "session"]);
335 }
336 "windsurf" => {
337 push(&[".windsurf"]);
338 push(&[".config", "windsurf"]);
339 }
340 _ => {}
341 }
342
343 out
344}
345
346fn detect_roots(
347 slug: &'static str,
348 roots: &[PathBuf],
349 source_label: &str,
350) -> InstalledAgentDetectionEntry {
351 let mut detected = false;
352 let mut evidence: Vec<String> = Vec::new();
353 let mut root_paths: Vec<String> = Vec::new();
354
355 if roots.is_empty() {
356 evidence.push("no probe roots available".to_string());
357 }
358
359 for root in roots {
360 let root_str = root.display().to_string();
361 if root.exists() {
362 detected = true;
363 root_paths.push(root_str.clone());
364 evidence.push(format!("{source_label} root exists: {root_str}"));
365 } else {
366 evidence.push(format!("{source_label} root missing: {root_str}"));
367 }
368 }
369
370 root_paths.sort();
371 InstalledAgentDetectionEntry {
372 slug: slug.to_string(),
373 detected,
374 evidence,
375 root_paths,
376 }
377}
378
379fn entry_from_detect(slug: &'static str) -> InstalledAgentDetectionEntry {
380 if let Some(override_roots) = env_override_roots(slug) {
381 return detect_roots(slug, &override_roots, "env");
382 }
383 let roots = default_probe_roots(slug);
384 detect_roots(slug, &roots, "default")
385}
386
387fn entry_from_override(slug: &'static str, roots: &[PathBuf]) -> InstalledAgentDetectionEntry {
388 detect_roots(slug, roots, "override")
389}
390
391fn build_overrides_map(overrides: &[AgentDetectRootOverride]) -> HashMap<String, Vec<PathBuf>> {
392 let mut out: HashMap<String, Vec<PathBuf>> = HashMap::new();
393 for override_root in overrides {
394 let Some(slug) = canonical_or_normalized_slug(&override_root.slug) else {
395 continue;
396 };
397 out.entry(slug)
398 .or_default()
399 .push(override_root.root.clone());
400 }
401 out
402}
403
404fn validate_known_connectors(
405 available: &HashSet<&'static str>,
406 only: Option<&HashSet<String>>,
407 overrides: &HashMap<String, Vec<PathBuf>>,
408) -> Result<(), AgentDetectError> {
409 let mut unknown: Vec<String> = Vec::new();
410 if let Some(only) = only {
411 unknown.extend(
412 only.iter()
413 .filter(|slug| !available.contains(slug.as_str()))
414 .cloned(),
415 );
416 }
417 unknown.extend(
418 overrides
419 .keys()
420 .filter(|slug| !available.contains(slug.as_str()))
421 .cloned(),
422 );
423 if unknown.is_empty() {
424 return Ok(());
425 }
426 unknown.sort();
427 unknown.dedup();
428 Err(AgentDetectError::UnknownConnectors {
429 connectors: unknown,
430 })
431}
432
433#[must_use]
440#[allow(clippy::too_many_lines)]
441pub fn default_probe_paths_tilde() -> Vec<(&'static str, Vec<String>)> {
442 fn tilde(parts: &[&str]) -> String {
443 let mut path = String::from("~/");
444 for (i, part) in parts.iter().enumerate() {
445 if i > 0 {
446 path.push('/');
447 }
448 path.push_str(part);
449 }
450 path
451 }
452
453 KNOWN_CONNECTORS
454 .iter()
455 .map(|&slug| {
456 let paths: Vec<String> = match slug {
457 "aider" => vec![tilde(&[".aider.chat.history.md"]), tilde(&[".aider"])],
458 "amp" => vec![
459 tilde(&[".local", "share", "amp"]),
460 tilde(&[
461 ".config",
462 "Code",
463 "User",
464 "globalStorage",
465 "sourcegraph.amp",
466 ]),
467 tilde(&[
468 "Library",
469 "Application Support",
470 "Code",
471 "User",
472 "globalStorage",
473 "sourcegraph.amp",
474 ]),
475 ],
476 "chatgpt" => vec![tilde(&[
477 "Library",
478 "Application Support",
479 "com.openai.chat",
480 ])],
481 "claude" => vec![tilde(&[".claude", "projects"]), tilde(&[".claude"])],
482 "clawdbot" => vec![tilde(&[".clawdbot", "sessions"]), tilde(&[".clawdbot"])],
483 "cline" => vec![
484 tilde(&[
485 ".config",
486 "Code",
487 "User",
488 "globalStorage",
489 "saoudrizwan.claude-dev",
490 ]),
491 tilde(&[
492 ".config",
493 "Cursor",
494 "User",
495 "globalStorage",
496 "saoudrizwan.claude-dev",
497 ]),
498 tilde(&[
499 "Library",
500 "Application Support",
501 "Code",
502 "User",
503 "globalStorage",
504 "saoudrizwan.claude-dev",
505 ]),
506 tilde(&[
507 "Library",
508 "Application Support",
509 "Cursor",
510 "User",
511 "globalStorage",
512 "saoudrizwan.claude-dev",
513 ]),
514 ],
515 "codex" => vec![tilde(&[".codex", "sessions"])],
516 "continue" => vec![tilde(&[".continue", "sessions"])],
517 "copilot_cli" => vec![
518 tilde(&[".copilot", "session-state"]),
519 tilde(&[".copilot", "history-session-state"]),
520 tilde(&[".config", "gh-copilot"]),
521 tilde(&[".config", "gh", "copilot"]),
522 tilde(&[".local", "share", "github-copilot"]),
523 ],
524 "cursor" => vec![tilde(&[".cursor"])],
525 "factory" => vec![tilde(&[".factory", "sessions"])],
526 "gemini" => vec![tilde(&[".gemini", "tmp"]), tilde(&[".gemini"])],
527 "github-copilot" => vec![
528 tilde(&[
529 ".config",
530 "Code",
531 "User",
532 "globalStorage",
533 "github.copilot-chat",
534 ]),
535 tilde(&[
536 "Library",
537 "Application Support",
538 "Code",
539 "User",
540 "globalStorage",
541 "github.copilot-chat",
542 ]),
543 tilde(&[".config", "gh-copilot"]),
544 tilde(&[".copilot", "session-state"]),
546 tilde(&[".copilot", "history-session-state"]),
548 ],
549 "goose" => vec![tilde(&[".goose", "sessions"])],
550 "kimi" => vec![tilde(&[".kimi", "sessions"])],
551 "opencode" => vec![tilde(&[".local", "share", "opencode"])],
552 "openclaw" => vec![tilde(&[".openclaw", "agents"])],
553 "pi_agent" => vec![tilde(&[".pi", "agent", "sessions"])],
554 "qwen" => vec![tilde(&[".qwen", "tmp"])],
555 "vibe" => vec![tilde(&[".vibe", "logs", "session"])],
556 "windsurf" => vec![tilde(&[".windsurf"])],
557 _ => vec![],
558 };
559 (slug, paths)
560 })
561 .collect()
562}
563
564#[allow(clippy::missing_const_for_fn)]
572pub fn detect_installed_agents(
573 opts: &AgentDetectOptions,
574) -> Result<InstalledAgentDetectionReport, AgentDetectError> {
575 let available: HashSet<&'static str> = KNOWN_CONNECTORS.iter().copied().collect();
576 let overrides = build_overrides_map(&opts.root_overrides);
577
578 let only: Option<HashSet<String>> = opts.only_connectors.as_ref().map(|slugs| {
579 slugs
580 .iter()
581 .filter_map(|slug| canonical_or_normalized_slug(slug))
582 .collect()
583 });
584
585 validate_known_connectors(&available, only.as_ref(), &overrides)?;
586
587 let mut all_entries: Vec<InstalledAgentDetectionEntry> = KNOWN_CONNECTORS
588 .iter()
589 .copied()
590 .filter(|slug| only.as_ref().is_none_or(|set| set.contains(*slug)))
591 .map(|slug| {
592 overrides.get(slug).map_or_else(
593 || entry_from_detect(slug),
594 |roots| entry_from_override(slug, roots),
595 )
596 })
597 .collect();
598
599 all_entries.sort_by(|a, b| a.slug.cmp(&b.slug));
600
601 let detected_count = all_entries.iter().filter(|entry| entry.detected).count();
602 let total_count = all_entries.len();
603
604 Ok(InstalledAgentDetectionReport {
605 format_version: 1,
606 generated_at: chrono::Utc::now().to_rfc3339(),
607 installed_agents: if opts.include_undetected {
608 all_entries
609 } else {
610 all_entries
611 .into_iter()
612 .filter(|entry| entry.detected)
613 .collect()
614 },
615 summary: InstalledAgentDetectionSummary {
616 detected_count,
617 total_count,
618 },
619 })
620}
621
622#[cfg(test)]
623mod tests {
624 use super::*;
625
626 #[test]
627 fn detect_installed_agents_can_be_scoped_to_specific_connectors() {
628 let tmp = tempfile::tempdir().expect("tempdir");
629
630 let codex_root = tmp.path().join("codex-home").join("sessions");
631 std::fs::create_dir_all(&codex_root).expect("create codex sessions");
632
633 let gemini_root = tmp.path().join("gemini-home").join("tmp");
634 std::fs::create_dir_all(&gemini_root).expect("create gemini root");
635
636 let report = detect_installed_agents(&AgentDetectOptions {
637 only_connectors: Some(vec!["codex".to_string(), "gemini".to_string()]),
638 include_undetected: true,
639 root_overrides: vec![
640 AgentDetectRootOverride {
641 slug: "codex".to_string(),
642 root: codex_root,
643 },
644 AgentDetectRootOverride {
645 slug: "gemini".to_string(),
646 root: gemini_root.clone(),
647 },
648 ],
649 })
650 .expect("detect");
651
652 assert_eq!(report.format_version, 1);
653 assert!(!report.generated_at.is_empty());
654 assert_eq!(report.summary.total_count, 2);
655 assert_eq!(report.summary.detected_count, 2);
656
657 let slugs: Vec<&str> = report
658 .installed_agents
659 .iter()
660 .map(|entry| entry.slug.as_str())
661 .collect();
662 assert_eq!(slugs, vec!["codex", "gemini"]);
663
664 let codex = report
665 .installed_agents
666 .iter()
667 .find(|entry| entry.slug == "codex")
668 .expect("codex entry");
669 assert!(codex.detected);
670 assert!(
671 codex
672 .root_paths
673 .iter()
674 .any(|path| path.ends_with("/sessions"))
675 );
676
677 let gemini = report
678 .installed_agents
679 .iter()
680 .find(|entry| entry.slug == "gemini")
681 .expect("gemini entry");
682 assert!(gemini.detected);
683 assert_eq!(gemini.root_paths, vec![gemini_root.display().to_string()]);
684 }
685
686 #[test]
687 fn unknown_connectors_are_rejected() {
688 let err = detect_installed_agents(&AgentDetectOptions {
689 only_connectors: Some(vec!["not-a-real-connector".to_string()]),
690 include_undetected: true,
691 root_overrides: vec![],
692 })
693 .expect_err("should error");
694
695 match err {
696 AgentDetectError::UnknownConnectors { connectors } => {
697 assert_eq!(connectors, vec!["not-a-real-connector".to_string()]);
698 }
699 AgentDetectError::FeatureDisabled => {
700 panic!("unexpected error: FeatureDisabled")
701 }
702 }
703 }
704
705 #[test]
706 fn unknown_overrides_are_rejected() {
707 let tmp = tempfile::tempdir().expect("tempdir");
708 let err = detect_installed_agents(&AgentDetectOptions {
709 only_connectors: Some(vec!["codex".to_string()]),
710 include_undetected: true,
711 root_overrides: vec![AgentDetectRootOverride {
712 slug: "definitely-unknown".to_string(),
713 root: tmp.path().join("does-not-matter"),
714 }],
715 })
716 .expect_err("should error");
717
718 match err {
719 AgentDetectError::UnknownConnectors { connectors } => {
720 assert_eq!(connectors, vec!["definitely-unknown".to_string()]);
721 }
722 AgentDetectError::FeatureDisabled => {
723 panic!("unexpected error: FeatureDisabled")
724 }
725 }
726 }
727
728 #[test]
729 fn cass_connectors_and_aliases_detect_via_overrides() {
730 let tmp = tempfile::tempdir().expect("tempdir");
731
732 let aider_file = tmp.path().join("aider").join(".aider.chat.history.md");
733 std::fs::create_dir_all(aider_file.parent().expect("aider parent")).expect("mkdir aider");
734 std::fs::write(&aider_file, "stub").expect("write aider file");
735
736 let amp_root = tmp.path().join("amp-root");
737 std::fs::create_dir_all(&_root).expect("mkdir amp");
738
739 let chatgpt_root = tmp.path().join("chatgpt-root");
740 std::fs::create_dir_all(&chatgpt_root).expect("mkdir chatgpt");
741
742 let clawdbot_sessions = tmp.path().join("clawdbot").join("sessions");
743 std::fs::create_dir_all(&clawdbot_sessions).expect("mkdir clawdbot");
744
745 let openclaw_agents = tmp.path().join("openclaw").join("agents");
746 std::fs::create_dir_all(&openclaw_agents).expect("mkdir openclaw");
747
748 let pi_sessions = tmp.path().join("pi").join("agent").join("sessions");
749 std::fs::create_dir_all(&pi_sessions).expect("mkdir pi");
750
751 let vibe_sessions = tmp.path().join("vibe").join("logs").join("session");
752 std::fs::create_dir_all(&vibe_sessions).expect("mkdir vibe");
753
754 let report = detect_installed_agents(&AgentDetectOptions {
755 only_connectors: Some(vec![
756 "aider".to_string(),
757 "amp".to_string(),
758 "chatgpt".to_string(),
759 "clawdbot".to_string(),
760 "open-claw".to_string(),
761 "pi-agent".to_string(),
762 "vibe".to_string(),
763 ]),
764 include_undetected: true,
765 root_overrides: vec![
766 AgentDetectRootOverride {
767 slug: "aider-cli".to_string(),
768 root: aider_file,
769 },
770 AgentDetectRootOverride {
771 slug: "amp".to_string(),
772 root: amp_root,
773 },
774 AgentDetectRootOverride {
775 slug: "chatgpt-desktop".to_string(),
776 root: chatgpt_root,
777 },
778 AgentDetectRootOverride {
779 slug: "clawdbot".to_string(),
780 root: clawdbot_sessions,
781 },
782 AgentDetectRootOverride {
783 slug: "open-claw".to_string(),
784 root: openclaw_agents,
785 },
786 AgentDetectRootOverride {
787 slug: "pi-agent".to_string(),
788 root: pi_sessions.clone(),
789 },
790 AgentDetectRootOverride {
791 slug: "vibe-cli".to_string(),
792 root: vibe_sessions,
793 },
794 ],
795 })
796 .expect("detect");
797
798 assert_eq!(report.summary.total_count, 7);
799 assert_eq!(report.summary.detected_count, 7);
800
801 let slugs: Vec<&str> = report
802 .installed_agents
803 .iter()
804 .map(|entry| entry.slug.as_str())
805 .collect();
806 assert_eq!(
807 slugs,
808 vec![
809 "aider", "amp", "chatgpt", "clawdbot", "openclaw", "pi_agent", "vibe"
810 ]
811 );
812
813 let pi = report
814 .installed_agents
815 .iter()
816 .find(|entry| entry.slug == "pi_agent")
817 .expect("pi_agent entry");
818 assert_eq!(pi.root_paths, vec![pi_sessions.display().to_string()]);
819 }
820}