syncable_cli/agent/ide/
detect.rs

1//! IDE Detection
2//!
3//! Detects which IDE the CLI is running in by examining environment variables
4//! and traversing the process tree to find the IDE process.
5
6use std::env;
7use std::process::Command;
8
9/// Information about a detected IDE
10#[derive(Debug, Clone)]
11pub struct IdeInfo {
12    pub name: String,
13    pub display_name: String,
14}
15
16/// Known IDE definitions
17pub mod ide_definitions {
18    use super::IdeInfo;
19
20    pub fn vscode() -> IdeInfo {
21        IdeInfo {
22            name: "vscode".to_string(),
23            display_name: "VS Code".to_string(),
24        }
25    }
26
27    pub fn cursor() -> IdeInfo {
28        IdeInfo {
29            name: "cursor".to_string(),
30            display_name: "Cursor".to_string(),
31        }
32    }
33
34    pub fn codespaces() -> IdeInfo {
35        IdeInfo {
36            name: "codespaces".to_string(),
37            display_name: "GitHub Codespaces".to_string(),
38        }
39    }
40
41    pub fn vscodefork() -> IdeInfo {
42        IdeInfo {
43            name: "vscodefork".to_string(),
44            display_name: "IDE".to_string(),
45        }
46    }
47
48    pub fn windsurf() -> IdeInfo {
49        IdeInfo {
50            name: "windsurf".to_string(),
51            display_name: "Windsurf".to_string(),
52        }
53    }
54
55    pub fn zed() -> IdeInfo {
56        IdeInfo {
57            name: "zed".to_string(),
58            display_name: "Zed".to_string(),
59        }
60    }
61}
62
63/// Detect IDE from environment variables
64pub fn detect_ide_from_env() -> Option<IdeInfo> {
65    // Check for Cursor
66    if env::var("CURSOR_TRACE_ID").is_ok() {
67        return Some(ide_definitions::cursor());
68    }
69
70    // Check for GitHub Codespaces
71    if env::var("CODESPACES").is_ok() {
72        return Some(ide_definitions::codespaces());
73    }
74
75    // Check for Windsurf
76    if env::var("WINDSURF_TRACE_ID").is_ok() {
77        return Some(ide_definitions::windsurf());
78    }
79
80    // Check for Zed
81    if env::var("ZED_TERM").is_ok() {
82        return Some(ide_definitions::zed());
83    }
84
85    // Default to VS Code if TERM_PROGRAM is vscode
86    if env::var("TERM_PROGRAM").ok().as_deref() == Some("vscode") {
87        return Some(ide_definitions::vscode());
88    }
89
90    None
91}
92
93/// Verify if the detected IDE is actually VS Code or a fork
94fn verify_vscode(ide: IdeInfo, command: &str) -> IdeInfo {
95    if ide.name != "vscode" {
96        return ide;
97    }
98
99    // Check if the command indicates VS Code or a fork
100    let cmd_lower = command.to_lowercase();
101    if cmd_lower.contains("code") || cmd_lower.is_empty() {
102        ide_definitions::vscode()
103    } else {
104        ide_definitions::vscodefork()
105    }
106}
107
108/// Detect the IDE, using both environment and process information
109pub fn detect_ide(process_info: Option<&IdeProcessInfo>) -> Option<IdeInfo> {
110    // Only VSCode-based integrations are currently supported
111    if env::var("TERM_PROGRAM").ok().as_deref() != Some("vscode") {
112        return None;
113    }
114
115    let ide = detect_ide_from_env()?;
116
117    if let Some(info) = process_info {
118        Some(verify_vscode(ide, &info.command))
119    } else {
120        Some(ide)
121    }
122}
123
124/// Information about the IDE process
125#[derive(Debug, Clone)]
126pub struct IdeProcessInfo {
127    pub pid: u32,
128    pub command: String,
129}
130
131/// Get process info by traversing the process tree
132/// This finds the IDE process by walking up the parent chain
133#[cfg(unix)]
134pub async fn get_ide_process_info() -> Option<IdeProcessInfo> {
135    const MAX_TRAVERSAL_DEPTH: usize = 32;
136    let shells = ["zsh", "bash", "sh", "tcsh", "csh", "ksh", "fish", "dash"];
137
138    let mut current_pid = std::process::id();
139
140    for _ in 0..MAX_TRAVERSAL_DEPTH {
141        if let Some((parent_pid, name, _command)) = get_process_info(current_pid) {
142            let is_shell = shells.iter().any(|&s| name == s);
143
144            if is_shell {
145                // Found a shell, the IDE is the grandparent
146                // First get the parent of the shell (often ptyhost or similar)
147                let mut ide_pid = parent_pid;
148
149                // Try to get the grandparent (the actual IDE)
150                if let Some((grandparent_pid, _, _)) = get_process_info(parent_pid) {
151                    if grandparent_pid > 1 {
152                        ide_pid = grandparent_pid;
153                    }
154                }
155
156                // Get the command of the IDE process
157                if let Some((_, _, ide_command)) = get_process_info(ide_pid) {
158                    return Some(IdeProcessInfo {
159                        pid: ide_pid,
160                        command: ide_command,
161                    });
162                }
163
164                return Some(IdeProcessInfo {
165                    pid: ide_pid,
166                    command: String::new(),
167                });
168            }
169
170            if parent_pid <= 1 {
171                break;
172            }
173            current_pid = parent_pid;
174        } else {
175            break;
176        }
177    }
178
179    // Return current process info as fallback
180    get_process_info(current_pid).map(|(_, _, command)| IdeProcessInfo {
181        pid: current_pid,
182        command,
183    })
184}
185
186/// Get process info for a given PID (Unix)
187#[cfg(unix)]
188fn get_process_info(pid: u32) -> Option<(u32, String, String)> {
189    let output = Command::new("ps")
190        .args(["-o", "ppid=,command=", "-p", &pid.to_string()])
191        .output()
192        .ok()?;
193
194    let stdout = String::from_utf8_lossy(&output.stdout);
195    let trimmed = stdout.trim();
196
197    if trimmed.is_empty() {
198        return None;
199    }
200
201    let parts: Vec<&str> = trimmed.splitn(2, char::is_whitespace).collect();
202    if parts.is_empty() {
203        return None;
204    }
205
206    let parent_pid: u32 = parts[0].trim().parse().unwrap_or(1);
207    let full_command = parts.get(1).map(|s| s.trim()).unwrap_or("");
208    let process_name = full_command
209        .split_whitespace()
210        .next()
211        .map(|s| {
212            std::path::Path::new(s)
213                .file_name()
214                .map(|n| n.to_string_lossy().to_string())
215                .unwrap_or_default()
216        })
217        .unwrap_or_default();
218
219    Some((parent_pid, process_name, full_command.to_string()))
220}
221
222/// Get IDE process info for Windows
223#[cfg(windows)]
224pub async fn get_ide_process_info() -> Option<IdeProcessInfo> {
225    // Windows implementation using PowerShell
226    let output = Command::new("powershell")
227        .args([
228            "-Command",
229            "Get-CimInstance Win32_Process | Where-Object { $_.ProcessId -eq $PID } | Select-Object ParentProcessId | ConvertTo-Json"
230        ])
231        .output()
232        .ok()?;
233
234    // Simplified Windows implementation - just get the current process parent
235    let stdout = String::from_utf8_lossy(&output.stdout);
236
237    // For now, return a basic implementation
238    // A full implementation would traverse the process tree like gemini-cli does
239    Some(IdeProcessInfo {
240        pid: std::process::id(),
241        command: String::new(),
242    })
243}
244
245#[cfg(windows)]
246fn get_process_info(_pid: u32) -> Option<(u32, String, String)> {
247    // Windows implementation would use PowerShell
248    None
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    #[test]
256    fn test_detect_ide_from_env_vscode() {
257        // This test would need to mock environment variables
258        // Just testing that the function doesn't panic
259        let _ = detect_ide_from_env();
260    }
261
262    #[test]
263    fn test_ide_definitions() {
264        let vscode = ide_definitions::vscode();
265        assert_eq!(vscode.name, "vscode");
266        assert_eq!(vscode.display_name, "VS Code");
267
268        let cursor = ide_definitions::cursor();
269        assert_eq!(cursor.name, "cursor");
270    }
271}