bamboo_server/claude_runner/
command.rs1use 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 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 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 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 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 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 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 let new_dir = std::path::PathBuf::from("/new/bin");
213 let existing_path = "/usr/local/bin:/usr/bin:/bin";
214
215 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 assert!(new_path_str.starts_with("/new/bin"));
225 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 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}