syncable_cli/agent/ide/
detect.rs1use std::env;
7use std::process::Command;
8
9#[derive(Debug, Clone)]
11pub struct IdeInfo {
12 pub name: String,
13 pub display_name: String,
14}
15
16pub 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
63pub fn detect_ide_from_env() -> Option<IdeInfo> {
65 if env::var("CURSOR_TRACE_ID").is_ok() {
67 return Some(ide_definitions::cursor());
68 }
69
70 if env::var("CODESPACES").is_ok() {
72 return Some(ide_definitions::codespaces());
73 }
74
75 if env::var("WINDSURF_TRACE_ID").is_ok() {
77 return Some(ide_definitions::windsurf());
78 }
79
80 if env::var("ZED_TERM").is_ok() {
82 return Some(ide_definitions::zed());
83 }
84
85 if env::var("TERM_PROGRAM").ok().as_deref() == Some("vscode") {
87 return Some(ide_definitions::vscode());
88 }
89
90 None
91}
92
93fn verify_vscode(ide: IdeInfo, command: &str) -> IdeInfo {
95 if ide.name != "vscode" {
96 return ide;
97 }
98
99 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
108pub fn detect_ide(process_info: Option<&IdeProcessInfo>) -> Option<IdeInfo> {
110 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#[derive(Debug, Clone)]
126pub struct IdeProcessInfo {
127 pub pid: u32,
128 pub command: String,
129}
130
131#[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 let mut ide_pid = parent_pid;
148
149 if let Some((grandparent_pid, _, _)) = get_process_info(parent_pid) {
151 if grandparent_pid > 1 {
152 ide_pid = grandparent_pid;
153 }
154 }
155
156 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 get_process_info(current_pid).map(|(_, _, command)| IdeProcessInfo {
181 pid: current_pid,
182 command,
183 })
184}
185
186#[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#[cfg(windows)]
224pub async fn get_ide_process_info() -> Option<IdeProcessInfo> {
225 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 let stdout = String::from_utf8_lossy(&output.stdout);
236
237 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 None
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254
255 #[test]
256 fn test_detect_ide_from_env_vscode() {
257 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}