Skip to main content

bamboo_server/claude_runner/
command.rs

1use bamboo_infrastructure::{
2    hide_window_for_std_command, hide_window_for_tokio_command, trace_windows_command,
3};
4use tracing::{debug, info};
5
6#[cfg(windows)]
7fn resolve_windows_program(program: &str) -> (String, Vec<String>) {
8    use std::path::Path;
9
10    // NPM on Windows commonly creates BOTH:
11    // - claude (a non-executable shim script for Unix)
12    // - claude.cmd (the actual Windows launcher)
13    //
14    // If the configured path points at the no-extension shim, CreateProcess fails with:
15    // "%1 is not a valid Win32 application" (os error 193).
16    let p = Path::new(program);
17
18    let mut resolved = program.to_string();
19    let ext = p
20        .extension()
21        .and_then(|s| s.to_str())
22        .unwrap_or("")
23        .to_ascii_lowercase();
24
25    if ext.is_empty() {
26        // If the path exists as a file, prefer a sibling .exe/.cmd/.bat.
27        if p.exists() && p.is_file() {
28            let exe = p.with_extension("exe");
29            let cmd = p.with_extension("cmd");
30            let bat = p.with_extension("bat");
31            if exe.exists() && exe.is_file() {
32                resolved = exe.to_string_lossy().to_string();
33            } else if cmd.exists() && cmd.is_file() {
34                resolved = cmd.to_string_lossy().to_string();
35            } else if bat.exists() && bat.is_file() {
36                resolved = bat.to_string_lossy().to_string();
37            }
38        } else if program.eq_ignore_ascii_case("claude") {
39            // For PATH lookups, prefer the Windows shim.
40            resolved = "claude.cmd".to_string();
41        }
42    }
43
44    let resolved_ext = Path::new(&resolved)
45        .extension()
46        .and_then(|s| s.to_str())
47        .unwrap_or("")
48        .to_ascii_lowercase();
49
50    if resolved_ext == "cmd" || resolved_ext == "bat" {
51        ("cmd".to_string(), vec!["/C".to_string(), resolved])
52    } else {
53        (resolved, Vec::new())
54    }
55}
56
57#[cfg(not(windows))]
58fn resolve_windows_program(program: &str) -> (String, Vec<String>) {
59    (program.to_string(), Vec::new())
60}
61
62fn collect_inherited_env(program: &str) -> Vec<(String, String)> {
63    let mut envs: Vec<(String, String)> = Vec::new();
64
65    for (key, value) in std::env::vars() {
66        if key == "PATH"
67            || key == "HOME"
68            || key == "USER"
69            || key == "SHELL"
70            || key == "LANG"
71            || key == "LC_ALL"
72            || key.starts_with("LC_")
73            || key == "NODE_PATH"
74            || key == "NVM_DIR"
75            || key == "NVM_BIN"
76            || key == "HOMEBREW_PREFIX"
77            || key == "HOMEBREW_CELLAR"
78            || key == "HTTP_PROXY"
79            || key == "HTTPS_PROXY"
80            || key == "NO_PROXY"
81            || key == "ALL_PROXY"
82        {
83            debug!("Inheriting env var: {}={}", key, value);
84            envs.push((key, value));
85        }
86    }
87
88    // Ensure PATH contains the directory of the selected binary for common install layouts.
89    let mut path_value = envs
90        .iter()
91        .find(|(k, _)| k == "PATH")
92        .map(|(_, v)| v.clone())
93        .unwrap_or_default();
94
95    if program.contains("/.nvm/versions/node/") {
96        if let Some(node_bin_dir) = std::path::Path::new(program).parent() {
97            let node_bin_str = node_bin_dir.to_string_lossy();
98            if !path_value.contains(node_bin_str.as_ref()) {
99                let joined = std::env::join_paths(
100                    std::iter::once(node_bin_dir.to_path_buf())
101                        .chain(std::env::split_paths(&path_value)),
102                )
103                .map(|os| os.to_string_lossy().to_string())
104                .unwrap_or_else(|_| format!("{}:{}", node_bin_str, path_value));
105                debug!("Adding NVM bin directory to PATH: {}", node_bin_str);
106                path_value = joined;
107            }
108        }
109    }
110
111    if program.contains("/homebrew/") || program.contains("/opt/homebrew/") {
112        if let Some(program_dir) = std::path::Path::new(program).parent() {
113            let homebrew_bin_str = program_dir.to_string_lossy();
114            if !path_value.contains(homebrew_bin_str.as_ref()) {
115                let joined = std::env::join_paths(
116                    std::iter::once(program_dir.to_path_buf())
117                        .chain(std::env::split_paths(&path_value)),
118                )
119                .map(|os| os.to_string_lossy().to_string())
120                .unwrap_or_else(|_| format!("{}:{}", homebrew_bin_str, path_value));
121                debug!(
122                    "Adding Homebrew bin directory to PATH: {}",
123                    homebrew_bin_str
124                );
125                path_value = joined;
126            }
127        }
128    }
129
130    if let Some((_, v)) = envs.iter_mut().find(|(k, _)| k == "PATH") {
131        *v = path_value;
132    } else if !path_value.is_empty() {
133        envs.push(("PATH".to_string(), path_value));
134    }
135
136    envs
137}
138
139fn log_proxy_settings() {
140    info!("Command will use proxy settings:");
141    if let Ok(http_proxy) = std::env::var("HTTP_PROXY") {
142        info!("  HTTP_PROXY={}", http_proxy);
143    }
144    if let Ok(https_proxy) = std::env::var("HTTPS_PROXY") {
145        info!("  HTTPS_PROXY={}", https_proxy);
146    }
147}
148
149pub fn create_command_with_env(program: &str) -> std::process::Command {
150    let (exe, prefix_args) = resolve_windows_program(program);
151    let mut cmd = std::process::Command::new(&exe);
152    hide_window_for_std_command(&mut cmd);
153    trace_windows_command(
154        "claude.create_command_with_env",
155        &exe,
156        prefix_args.iter().map(String::as_str),
157    );
158    cmd.args(prefix_args);
159    info!("Creating command for: {} (exec: {})", program, exe);
160    for (key, value) in collect_inherited_env(program) {
161        cmd.env(&key, &value);
162    }
163    log_proxy_settings();
164    cmd
165}
166
167pub fn create_tokio_command_with_env(program: &str) -> tokio::process::Command {
168    let (exe, prefix_args) = resolve_windows_program(program);
169    let mut cmd = tokio::process::Command::new(&exe);
170    hide_window_for_tokio_command(&mut cmd);
171    trace_windows_command(
172        "claude.create_tokio_command_with_env",
173        &exe,
174        prefix_args.iter().map(String::as_str),
175    );
176    cmd.args(prefix_args);
177    info!("Creating tokio command for: {} (exec: {})", program, exe);
178    for (key, value) in collect_inherited_env(program) {
179        cmd.env(&key, &value);
180    }
181    log_proxy_settings();
182
183    cmd
184}
185
186#[cfg(test)]
187mod tests {
188    #[test]
189    fn test_join_paths_with_env_functions() {
190        // Test that std::env::join_paths and split_paths work correctly
191        // This is the cross-platform way to handle PATH environment variable
192        let paths = vec![
193            std::path::PathBuf::from("/usr/local/bin"),
194            std::path::PathBuf::from("/usr/bin"),
195            std::path::PathBuf::from("/bin"),
196        ];
197
198        let joined = std::env::join_paths(&paths).expect("Failed to join paths");
199        let joined_str = joined.to_str().expect("Invalid UTF-8");
200
201        // Verify we can split it back
202        let split: Vec<_> = std::env::split_paths(joined_str).collect();
203        assert_eq!(split.len(), 3);
204        assert!(split.contains(&std::path::PathBuf::from("/usr/local/bin")));
205        assert!(split.contains(&std::path::PathBuf::from("/usr/bin")));
206        assert!(split.contains(&std::path::PathBuf::from("/bin")));
207    }
208
209    #[test]
210    fn test_join_paths_prepends_to_existing_path() {
211        // Simulate prepending a new directory to PATH
212        let new_dir = std::path::PathBuf::from("/new/bin");
213        let existing_path = "/usr/local/bin:/usr/bin:/bin";
214
215        // Use the same pattern as in the actual code
216        let new_path = std::env::join_paths(
217            std::iter::once(new_dir.clone()).chain(std::env::split_paths(existing_path)),
218        );
219
220        assert!(new_path.is_ok());
221        let new_path_str = new_path.unwrap().to_str().unwrap().to_string();
222
223        // The new directory should be first
224        assert!(new_path_str.starts_with("/new/bin"));
225        // Should contain all original paths
226        assert!(new_path_str.contains("/usr/local/bin"));
227        assert!(new_path_str.contains("/usr/bin"));
228        assert!(new_path_str.contains("/bin"));
229    }
230
231    #[test]
232    fn test_path_separator_is_platform_specific() {
233        // This test documents that we're using std::env::join_paths
234        // which handles the platform-specific separator automatically
235        // On Unix: ':'
236        // On Windows: ';'
237        let paths = vec![
238            std::path::PathBuf::from("/first"),
239            std::path::PathBuf::from("/second"),
240        ];
241
242        let joined = std::env::join_paths(&paths).unwrap();
243        let joined_str = joined.to_str().unwrap();
244
245        #[cfg(unix)]
246        assert!(joined_str.contains(':'), "Unix uses ':' as PATH separator");
247
248        #[cfg(windows)]
249        assert!(
250            joined_str.contains(';'),
251            "Windows uses ';' as PATH separator"
252        );
253    }
254}