Skip to main content

franken_agent_detection/
lib.rs

1//! Local coding-agent installation detection.
2//!
3//! Provides synchronous, filesystem-based probes for known coding-agent CLIs.
4//!
5//! ## Types
6//!
7//! The [`types`] module contains normalized types for representing agent conversations:
8//! - [`DetectionResult`](types::DetectionResult) — always available
9//! - [`NormalizedConversation`], [`NormalizedMessage`], [`NormalizedSnippet`]
10//!   — available with the `connectors` feature
11
12#![forbid(unsafe_code)]
13
14#[cfg(feature = "connectors")]
15pub mod connectors;
16pub mod types;
17
18// Re-export core types at crate root for convenience.
19pub use types::DetectionResult;
20#[cfg(feature = "connectors")]
21pub use types::{
22    // Scan & provenance types
23    LOCAL_SOURCE_ID,
24    NormalizedConversation,
25    NormalizedMessage,
26    NormalizedSnippet,
27    Origin,
28    PathMapping,
29    Platform,
30    SourceKind,
31    reindex_messages,
32};
33// Re-export connector infrastructure at crate root.
34#[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    /// Restrict detection to specific connector slugs (e.g. `["codex", "gemini"]`).
62    ///
63    /// When `None`, all known connectors are evaluated.
64    pub only_connectors: Option<Vec<String>>,
65
66    /// When false, omit entries that were not detected.
67    pub include_undetected: bool,
68
69    /// Optional per-connector root overrides for deterministic detection (tests/fixtures).
70    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    /// Stable connector/agent identifier (e.g. `codex`, `claude`, `gemini`).
88    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/// Returns default probe paths for all known connectors using tilde-relative paths.
434///
435/// These paths use `~/` prefix instead of resolved home directories, making them
436/// suitable for SSH probe scripts where the remote home directory is unknown.
437/// Each entry is `(slug, paths)` where `paths` are bash-friendly strings like
438/// `~/.claude/projects`.
439#[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                    // Copilot CLI session-state (v2, since 0.0.342)
545                    tilde(&[".copilot", "session-state"]),
546                    // Copilot CLI legacy session-state (v1)
547                    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/// Detect installed/available coding agents by running local filesystem probes.
565///
566/// This returns a stable JSON shape (via `serde`) intended for CLI/resource consumption.
567///
568/// # Errors
569/// Returns [`AgentDetectError::UnknownConnectors`] when `only_connectors`
570/// includes unknown slugs.
571#[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(&amp_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}