1use std::time::Duration;
12
13#[cfg(feature = "async")]
14use tokio::io::AsyncReadExt;
15#[cfg(feature = "async")]
16use tokio::process::Command;
17use tracing::{debug, warn};
18
19use crate::Claude;
20use crate::error::{Error, Result};
21
22#[derive(Debug, Clone)]
24pub struct CommandOutput {
25 pub stdout: String,
27 pub stderr: String,
29 pub exit_code: i32,
31 pub success: bool,
33}
34
35#[cfg(feature = "async")]
41pub async fn run_claude(claude: &Claude, args: Vec<String>) -> Result<CommandOutput> {
42 run_claude_with_retry(claude, args, None).await
43}
44
45#[cfg(feature = "async")]
47pub async fn run_claude_with_retry(
48 claude: &Claude,
49 args: Vec<String>,
50 retry_override: Option<&crate::retry::RetryPolicy>,
51) -> Result<CommandOutput> {
52 let policy = retry_override.or(claude.retry_policy.as_ref());
53
54 match policy {
55 Some(policy) => {
56 crate::retry::with_retry(policy, || run_claude_once(claude, args.clone())).await
57 }
58 None => run_claude_once(claude, args).await,
59 }
60}
61
62#[cfg(feature = "async")]
68pub async fn run_claude_with_stdin_prompt(
69 claude: &Claude,
70 args: Vec<String>,
71 stdin_content: String,
72) -> Result<CommandOutput> {
73 run_claude_with_stdin_prompt_internal(claude, args, stdin_content).await
74}
75
76#[cfg(feature = "async")]
77async fn run_claude_with_stdin_prompt_internal(
78 claude: &Claude,
79 args: Vec<String>,
80 stdin_content: String,
81) -> Result<CommandOutput> {
82 let mut command_args = Vec::new();
83 command_args.extend(claude.global_args.clone());
84 command_args.extend(args);
85
86 debug!(binary = %claude.binary.display(), args = ?command_args, "executing claude command (stdin prompt)");
87
88 let binary = &claude.binary;
89 let env = &claude.env;
90 let working_dir = claude.working_dir.as_deref();
91
92 if let Some(timeout) = claude.timeout {
93 run_with_timeout_stdin(
94 binary,
95 &command_args,
96 env,
97 working_dir,
98 timeout,
99 stdin_content,
100 )
101 .await
102 } else {
103 run_internal_stdin(binary, &command_args, env, working_dir, stdin_content).await
104 }
105}
106
107#[cfg(feature = "async")]
108async fn run_internal_stdin(
109 binary: &std::path::Path,
110 args: &[String],
111 env: &std::collections::HashMap<String, String>,
112 working_dir: Option<&std::path::Path>,
113 stdin_content: String,
114) -> Result<CommandOutput> {
115 use tokio::io::AsyncWriteExt;
116
117 let mut cmd = Command::new(binary);
118 cmd.args(args);
119 cmd.stdin(std::process::Stdio::piped());
120 cmd.stdout(std::process::Stdio::piped());
121 cmd.stderr(std::process::Stdio::piped());
122 cmd.env_remove("CLAUDECODE");
123 cmd.env_remove("CLAUDE_CODE_ENTRYPOINT");
124
125 if let Some(dir) = working_dir {
126 cmd.current_dir(dir);
127 }
128
129 for (key, value) in env {
130 cmd.env(key, value);
131 }
132
133 let mut child = spawn_retrying_txtbsy(&mut cmd)
134 .await
135 .map_err(|e| Error::Io {
136 message: format!("failed to spawn claude: {e}"),
137 source: e,
138 working_dir: working_dir.map(|p| p.to_path_buf()),
139 })?;
140
141 if let Some(mut stdin) = child.stdin.take() {
143 stdin
144 .write_all(stdin_content.as_bytes())
145 .await
146 .map_err(|e| Error::Io {
147 message: format!("failed to write to claude stdin: {e}"),
148 source: e,
149 working_dir: working_dir.map(|p| p.to_path_buf()),
150 })?;
151 }
153
154 let mut stdout_handle = child.stdout.take().expect("stdout was piped");
155 let mut stderr_handle = child.stderr.take().expect("stderr was piped");
156
157 let (status, stdout_str, stderr_str) = tokio::join!(
158 child.wait(),
159 drain(&mut stdout_handle),
160 drain(&mut stderr_handle),
161 );
162
163 let status = status.map_err(|e| Error::Io {
164 message: "failed to wait for claude process".to_string(),
165 source: e,
166 working_dir: working_dir.map(|p| p.to_path_buf()),
167 })?;
168
169 let exit_code = status.code().unwrap_or(-1);
170
171 if !status.success() {
172 return Err(Error::from_command_failure(
173 format!("{} {}", binary.display(), args.join(" ")),
174 exit_code,
175 stdout_str,
176 stderr_str,
177 working_dir.map(|p| p.to_path_buf()),
178 ));
179 }
180
181 Ok(CommandOutput {
182 stdout: stdout_str,
183 stderr: stderr_str,
184 exit_code,
185 success: true,
186 })
187}
188
189#[cfg(feature = "async")]
190async fn run_with_timeout_stdin(
191 binary: &std::path::Path,
192 args: &[String],
193 env: &std::collections::HashMap<String, String>,
194 working_dir: Option<&std::path::Path>,
195 timeout: Duration,
196 stdin_content: String,
197) -> Result<CommandOutput> {
198 use tokio::io::AsyncWriteExt;
199
200 let mut cmd = Command::new(binary);
201 cmd.args(args);
202 cmd.stdin(std::process::Stdio::piped());
203 cmd.stdout(std::process::Stdio::piped());
204 cmd.stderr(std::process::Stdio::piped());
205 cmd.env_remove("CLAUDECODE");
206 cmd.env_remove("CLAUDE_CODE_ENTRYPOINT");
207
208 if let Some(dir) = working_dir {
209 cmd.current_dir(dir);
210 }
211
212 for (key, value) in env {
213 cmd.env(key, value);
214 }
215
216 let mut child = spawn_retrying_txtbsy(&mut cmd)
217 .await
218 .map_err(|e| Error::Io {
219 message: format!("failed to spawn claude: {e}"),
220 source: e,
221 working_dir: working_dir.map(|p| p.to_path_buf()),
222 })?;
223
224 if let Some(mut stdin) = child.stdin.take() {
226 stdin
227 .write_all(stdin_content.as_bytes())
228 .await
229 .map_err(|e| Error::Io {
230 message: format!("failed to write to claude stdin: {e}"),
231 source: e,
232 working_dir: working_dir.map(|p| p.to_path_buf()),
233 })?;
234 }
236
237 let mut stdout_handle = child.stdout.take().expect("stdout was piped");
238 let mut stderr_handle = child.stderr.take().expect("stderr was piped");
239
240 let wait_and_drain = async {
241 let (status, stdout_str, stderr_str) = tokio::join!(
242 child.wait(),
243 drain(&mut stdout_handle),
244 drain(&mut stderr_handle),
245 );
246 (status, stdout_str, stderr_str)
247 };
248
249 match tokio::time::timeout(timeout, wait_and_drain).await {
250 Ok((Ok(status), stdout, stderr)) => {
251 let exit_code = status.code().unwrap_or(-1);
252
253 if !status.success() {
254 return Err(Error::from_command_failure(
255 format!("{} {}", binary.display(), args.join(" ")),
256 exit_code,
257 stdout,
258 stderr,
259 working_dir.map(|p| p.to_path_buf()),
260 ));
261 }
262
263 Ok(CommandOutput {
264 stdout,
265 stderr,
266 exit_code,
267 success: true,
268 })
269 }
270 Ok((Err(e), _stdout, _stderr)) => Err(Error::Io {
271 message: "failed to wait for claude process".to_string(),
272 source: e,
273 working_dir: working_dir.map(|p| p.to_path_buf()),
274 }),
275 Err(_) => {
276 let _ = child.kill().await;
277 let drain_budget = Duration::from_millis(200);
278 let stdout_str = tokio::time::timeout(drain_budget, drain(&mut stdout_handle))
279 .await
280 .unwrap_or_default();
281 let stderr_str = tokio::time::timeout(drain_budget, drain(&mut stderr_handle))
282 .await
283 .unwrap_or_default();
284 if !stdout_str.is_empty() || !stderr_str.is_empty() {
285 warn!(
286 stdout = %stdout_str,
287 stderr = %stderr_str,
288 "partial output from timed-out process",
289 );
290 }
291 Err(Error::Timeout {
292 timeout_seconds: timeout.as_secs(),
293 })
294 }
295 }
296}
297
298#[cfg(feature = "async")]
299async fn run_claude_once(claude: &Claude, args: Vec<String>) -> Result<CommandOutput> {
300 let mut command_args = Vec::new();
301
302 command_args.extend(claude.global_args.clone());
304
305 command_args.extend(args);
307
308 debug!(binary = %claude.binary.display(), args = ?command_args, "executing claude command");
309
310 let output = if let Some(timeout) = claude.timeout {
311 run_with_timeout(
312 &claude.binary,
313 &command_args,
314 &claude.env,
315 claude.working_dir.as_deref(),
316 timeout,
317 )
318 .await?
319 } else {
320 run_internal(
321 &claude.binary,
322 &command_args,
323 &claude.env,
324 claude.working_dir.as_deref(),
325 )
326 .await?
327 };
328
329 Ok(output)
330}
331
332#[cfg(feature = "async")]
334pub async fn run_claude_allow_exit_codes(
335 claude: &Claude,
336 args: Vec<String>,
337 allowed_codes: &[i32],
338) -> Result<CommandOutput> {
339 let output = run_claude(claude, args).await;
340
341 match output {
342 Err(Error::CommandFailed {
343 exit_code,
344 stdout,
345 stderr,
346 ..
347 }) if allowed_codes.contains(&exit_code) => Ok(CommandOutput {
348 stdout,
349 stderr,
350 exit_code,
351 success: false,
352 }),
353 other => other,
354 }
355}
356
357#[cfg(feature = "async")]
358async fn run_internal(
359 binary: &std::path::Path,
360 args: &[String],
361 env: &std::collections::HashMap<String, String>,
362 working_dir: Option<&std::path::Path>,
363) -> Result<CommandOutput> {
364 let mut cmd = Command::new(binary);
365 cmd.args(args);
366
367 cmd.stdin(std::process::Stdio::null());
369
370 cmd.env_remove("CLAUDECODE");
372 cmd.env_remove("CLAUDE_CODE_ENTRYPOINT");
373
374 if let Some(dir) = working_dir {
375 cmd.current_dir(dir);
376 }
377
378 for (key, value) in env {
379 cmd.env(key, value);
380 }
381
382 let output = cmd.output().await.map_err(|e| Error::Io {
383 message: format!("failed to spawn claude: {e}"),
384 source: e,
385 working_dir: working_dir.map(|p| p.to_path_buf()),
386 })?;
387
388 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
389 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
390 let exit_code = output.status.code().unwrap_or(-1);
391
392 if !output.status.success() {
393 return Err(Error::from_command_failure(
394 format!("{} {}", binary.display(), args.join(" ")),
395 exit_code,
396 stdout,
397 stderr,
398 working_dir.map(|p| p.to_path_buf()),
399 ));
400 }
401
402 Ok(CommandOutput {
403 stdout,
404 stderr,
405 exit_code,
406 success: true,
407 })
408}
409
410#[cfg(feature = "async")]
422async fn run_with_timeout(
423 binary: &std::path::Path,
424 args: &[String],
425 env: &std::collections::HashMap<String, String>,
426 working_dir: Option<&std::path::Path>,
427 timeout: Duration,
428) -> Result<CommandOutput> {
429 let mut cmd = Command::new(binary);
430 cmd.args(args);
431 cmd.stdin(std::process::Stdio::null());
432 cmd.stdout(std::process::Stdio::piped());
433 cmd.stderr(std::process::Stdio::piped());
434 cmd.env_remove("CLAUDECODE");
435 cmd.env_remove("CLAUDE_CODE_ENTRYPOINT");
436
437 if let Some(dir) = working_dir {
438 cmd.current_dir(dir);
439 }
440
441 for (key, value) in env {
442 cmd.env(key, value);
443 }
444
445 let mut child = spawn_retrying_txtbsy(&mut cmd)
446 .await
447 .map_err(|e| Error::Io {
448 message: format!("failed to spawn claude: {e}"),
449 source: e,
450 working_dir: working_dir.map(|p| p.to_path_buf()),
451 })?;
452
453 let mut stdout = child.stdout.take().expect("stdout was piped");
454 let mut stderr = child.stderr.take().expect("stderr was piped");
455
456 let wait_and_drain = async {
461 let (status, stdout_str, stderr_str) =
462 tokio::join!(child.wait(), drain(&mut stdout), drain(&mut stderr));
463 (status, stdout_str, stderr_str)
464 };
465
466 match tokio::time::timeout(timeout, wait_and_drain).await {
467 Ok((Ok(status), stdout, stderr)) => {
468 let exit_code = status.code().unwrap_or(-1);
469
470 if !status.success() {
471 return Err(Error::from_command_failure(
472 format!("{} {}", binary.display(), args.join(" ")),
473 exit_code,
474 stdout,
475 stderr,
476 working_dir.map(|p| p.to_path_buf()),
477 ));
478 }
479
480 Ok(CommandOutput {
481 stdout,
482 stderr,
483 exit_code,
484 success: true,
485 })
486 }
487 Ok((Err(e), _stdout, _stderr)) => Err(Error::Io {
488 message: "failed to wait for claude process".to_string(),
489 source: e,
490 working_dir: working_dir.map(|p| p.to_path_buf()),
491 }),
492 Err(_) => {
493 let _ = child.kill().await;
499 let drain_budget = Duration::from_millis(200);
500 let stdout_str = tokio::time::timeout(drain_budget, drain(&mut stdout))
501 .await
502 .unwrap_or_default();
503 let stderr_str = tokio::time::timeout(drain_budget, drain(&mut stderr))
504 .await
505 .unwrap_or_default();
506 if !stdout_str.is_empty() || !stderr_str.is_empty() {
507 warn!(
508 stdout = %stdout_str,
509 stderr = %stderr_str,
510 "partial output from timed-out process",
511 );
512 }
513 Err(Error::Timeout {
514 timeout_seconds: timeout.as_secs(),
515 })
516 }
517 }
518}
519
520#[cfg(feature = "async")]
521async fn drain<R: AsyncReadExt + Unpin>(reader: &mut R) -> String {
522 let mut buf = Vec::new();
523 let _ = reader.read_to_end(&mut buf).await;
524 String::from_utf8_lossy(&buf).into_owned()
525}
526
527#[cfg(any(feature = "async", feature = "sync"))]
529const TXTBSY_RETRY_BUDGET: Duration = Duration::from_millis(500);
530
531#[cfg(feature = "async")]
542async fn spawn_retrying_txtbsy(cmd: &mut Command) -> std::io::Result<tokio::process::Child> {
543 let mut backoff = Duration::from_millis(1);
544 loop {
545 match cmd.spawn() {
546 Err(e)
547 if e.kind() == std::io::ErrorKind::ExecutableFileBusy
548 && backoff <= TXTBSY_RETRY_BUDGET =>
549 {
550 tokio::time::sleep(backoff).await;
551 backoff *= 2;
552 }
553 other => return other,
554 }
555 }
556}
557
558#[cfg(feature = "sync")]
562pub fn run_claude_sync(claude: &Claude, args: Vec<String>) -> Result<CommandOutput> {
563 run_claude_with_retry_sync(claude, args, None)
564}
565
566#[cfg(feature = "sync")]
568pub fn run_claude_with_retry_sync(
569 claude: &Claude,
570 args: Vec<String>,
571 retry_override: Option<&crate::retry::RetryPolicy>,
572) -> Result<CommandOutput> {
573 let policy = retry_override.or(claude.retry_policy.as_ref());
574
575 match policy {
576 Some(policy) => {
577 crate::retry::with_retry_sync(policy, || run_claude_once_sync(claude, args.clone()))
578 }
579 None => run_claude_once_sync(claude, args),
580 }
581}
582
583#[cfg(feature = "sync")]
588pub fn run_claude_with_stdin_prompt_sync(
589 claude: &Claude,
590 args: Vec<String>,
591 stdin_content: String,
592) -> Result<CommandOutput> {
593 let mut command_args = Vec::new();
594 command_args.extend(claude.global_args.clone());
595 command_args.extend(args);
596
597 debug!(binary = %claude.binary.display(), args = ?command_args, "executing claude command (stdin prompt, sync)");
598
599 if let Some(timeout) = claude.timeout {
600 run_with_timeout_stdin_sync(
601 &claude.binary,
602 &command_args,
603 &claude.env,
604 claude.working_dir.as_deref(),
605 timeout,
606 stdin_content,
607 )
608 } else {
609 run_internal_stdin_sync(
610 &claude.binary,
611 &command_args,
612 &claude.env,
613 claude.working_dir.as_deref(),
614 stdin_content,
615 )
616 }
617}
618
619#[cfg(feature = "sync")]
620fn run_internal_stdin_sync(
621 binary: &std::path::Path,
622 args: &[String],
623 env: &std::collections::HashMap<String, String>,
624 working_dir: Option<&std::path::Path>,
625 stdin_content: String,
626) -> Result<CommandOutput> {
627 use std::io::Write;
628 use std::process::{Command as StdCommand, Stdio};
629
630 let mut cmd = StdCommand::new(binary);
631 cmd.args(args);
632 cmd.stdin(Stdio::piped());
633 cmd.stdout(Stdio::piped());
634 cmd.stderr(Stdio::piped());
635 cmd.env_remove("CLAUDECODE");
636 cmd.env_remove("CLAUDE_CODE_ENTRYPOINT");
637
638 if let Some(dir) = working_dir {
639 cmd.current_dir(dir);
640 }
641
642 for (key, value) in env {
643 cmd.env(key, value);
644 }
645
646 let mut child = spawn_retrying_txtbsy_sync(&mut cmd).map_err(|e| Error::Io {
647 message: format!("failed to spawn claude: {e}"),
648 source: e,
649 working_dir: working_dir.map(|p| p.to_path_buf()),
650 })?;
651
652 if let Some(mut stdin) = child.stdin.take() {
654 stdin
655 .write_all(stdin_content.as_bytes())
656 .map_err(|e| Error::Io {
657 message: format!("failed to write to claude stdin: {e}"),
658 source: e,
659 working_dir: working_dir.map(|p| p.to_path_buf()),
660 })?;
661 stdin.flush().map_err(|e| Error::Io {
662 message: format!("failed to flush claude stdin: {e}"),
663 source: e,
664 working_dir: working_dir.map(|p| p.to_path_buf()),
665 })?;
666 }
668
669 let output = child.wait_with_output().map_err(|e| Error::Io {
670 message: "failed to wait for claude process".to_string(),
671 source: e,
672 working_dir: working_dir.map(|p| p.to_path_buf()),
673 })?;
674
675 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
676 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
677 let exit_code = output.status.code().unwrap_or(-1);
678
679 if !output.status.success() {
680 return Err(Error::from_command_failure(
681 format!("{} {}", binary.display(), args.join(" ")),
682 exit_code,
683 stdout,
684 stderr,
685 working_dir.map(|p| p.to_path_buf()),
686 ));
687 }
688
689 Ok(CommandOutput {
690 stdout,
691 stderr,
692 exit_code,
693 success: true,
694 })
695}
696
697#[cfg(feature = "sync")]
698fn run_with_timeout_stdin_sync(
699 binary: &std::path::Path,
700 args: &[String],
701 env: &std::collections::HashMap<String, String>,
702 working_dir: Option<&std::path::Path>,
703 timeout: Duration,
704 stdin_content: String,
705) -> Result<CommandOutput> {
706 use std::io::Write;
707 use std::process::{Command as StdCommand, Stdio};
708 use std::thread;
709 use wait_timeout::ChildExt;
710
711 let mut cmd = StdCommand::new(binary);
712 cmd.args(args);
713 cmd.stdin(Stdio::piped());
714 cmd.stdout(Stdio::piped());
715 cmd.stderr(Stdio::piped());
716 cmd.env_remove("CLAUDECODE");
717 cmd.env_remove("CLAUDE_CODE_ENTRYPOINT");
718
719 if let Some(dir) = working_dir {
720 cmd.current_dir(dir);
721 }
722
723 for (key, value) in env {
724 cmd.env(key, value);
725 }
726
727 let mut child = spawn_retrying_txtbsy_sync(&mut cmd).map_err(|e| Error::Io {
728 message: format!("failed to spawn claude: {e}"),
729 source: e,
730 working_dir: working_dir.map(|p| p.to_path_buf()),
731 })?;
732
733 if let Some(mut stdin) = child.stdin.take() {
735 stdin
736 .write_all(stdin_content.as_bytes())
737 .map_err(|e| Error::Io {
738 message: format!("failed to write to claude stdin: {e}"),
739 source: e,
740 working_dir: working_dir.map(|p| p.to_path_buf()),
741 })?;
742 stdin.flush().map_err(|e| Error::Io {
743 message: format!("failed to flush claude stdin: {e}"),
744 source: e,
745 working_dir: working_dir.map(|p| p.to_path_buf()),
746 })?;
747 }
749
750 let stdout = child.stdout.take().expect("stdout was piped");
751 let stderr = child.stderr.take().expect("stderr was piped");
752
753 let stdout_thread = thread::spawn(move || drain_sync(stdout));
754 let stderr_thread = thread::spawn(move || drain_sync(stderr));
755
756 match child.wait_timeout(timeout).map_err(|e| Error::Io {
757 message: "failed to wait for claude process".to_string(),
758 source: e,
759 working_dir: working_dir.map(|p| p.to_path_buf()),
760 })? {
761 Some(status) => {
762 let stdout = stdout_thread.join().unwrap_or_default();
763 let stderr = stderr_thread.join().unwrap_or_default();
764 let exit_code = status.code().unwrap_or(-1);
765
766 if !status.success() {
767 return Err(Error::from_command_failure(
768 format!("{} {}", binary.display(), args.join(" ")),
769 exit_code,
770 stdout,
771 stderr,
772 working_dir.map(|p| p.to_path_buf()),
773 ));
774 }
775
776 Ok(CommandOutput {
777 stdout,
778 stderr,
779 exit_code,
780 success: true,
781 })
782 }
783 None => {
784 let _ = child.kill();
785 let _ = child.wait();
786 let (stdout_str, stderr_str) =
787 join_with_deadline(stdout_thread, stderr_thread, Duration::from_millis(200));
788 if !stdout_str.is_empty() || !stderr_str.is_empty() {
789 warn!(
790 stdout = %stdout_str,
791 stderr = %stderr_str,
792 "partial output from timed-out process",
793 );
794 }
795 Err(Error::Timeout {
796 timeout_seconds: timeout.as_secs(),
797 })
798 }
799 }
800}
801
802#[cfg(feature = "sync")]
803fn run_claude_once_sync(claude: &Claude, args: Vec<String>) -> Result<CommandOutput> {
804 let mut command_args = Vec::new();
805 command_args.extend(claude.global_args.clone());
806 command_args.extend(args);
807
808 debug!(binary = %claude.binary.display(), args = ?command_args, "executing claude command (sync)");
809
810 if let Some(timeout) = claude.timeout {
811 run_with_timeout_sync(
812 &claude.binary,
813 &command_args,
814 &claude.env,
815 claude.working_dir.as_deref(),
816 timeout,
817 )
818 } else {
819 run_internal_sync(
820 &claude.binary,
821 &command_args,
822 &claude.env,
823 claude.working_dir.as_deref(),
824 )
825 }
826}
827
828#[cfg(feature = "sync")]
830pub fn run_claude_allow_exit_codes_sync(
831 claude: &Claude,
832 args: Vec<String>,
833 allowed_codes: &[i32],
834) -> Result<CommandOutput> {
835 match run_claude_sync(claude, args) {
836 Err(Error::CommandFailed {
837 exit_code,
838 stdout,
839 stderr,
840 ..
841 }) if allowed_codes.contains(&exit_code) => Ok(CommandOutput {
842 stdout,
843 stderr,
844 exit_code,
845 success: false,
846 }),
847 other => other,
848 }
849}
850
851#[cfg(feature = "sync")]
852fn run_internal_sync(
853 binary: &std::path::Path,
854 args: &[String],
855 env: &std::collections::HashMap<String, String>,
856 working_dir: Option<&std::path::Path>,
857) -> Result<CommandOutput> {
858 use std::process::{Command as StdCommand, Stdio};
859
860 let mut cmd = StdCommand::new(binary);
861 cmd.args(args);
862 cmd.stdin(Stdio::null());
863 cmd.env_remove("CLAUDECODE");
864 cmd.env_remove("CLAUDE_CODE_ENTRYPOINT");
865
866 if let Some(dir) = working_dir {
867 cmd.current_dir(dir);
868 }
869
870 for (key, value) in env {
871 cmd.env(key, value);
872 }
873
874 let output = cmd.output().map_err(|e| Error::Io {
875 message: format!("failed to spawn claude: {e}"),
876 source: e,
877 working_dir: working_dir.map(|p| p.to_path_buf()),
878 })?;
879
880 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
881 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
882 let exit_code = output.status.code().unwrap_or(-1);
883
884 if !output.status.success() {
885 return Err(Error::from_command_failure(
886 format!("{} {}", binary.display(), args.join(" ")),
887 exit_code,
888 stdout,
889 stderr,
890 working_dir.map(|p| p.to_path_buf()),
891 ));
892 }
893
894 Ok(CommandOutput {
895 stdout,
896 stderr,
897 exit_code,
898 success: true,
899 })
900}
901
902#[cfg(feature = "sync")]
909fn run_with_timeout_sync(
910 binary: &std::path::Path,
911 args: &[String],
912 env: &std::collections::HashMap<String, String>,
913 working_dir: Option<&std::path::Path>,
914 timeout: Duration,
915) -> Result<CommandOutput> {
916 use std::process::{Command as StdCommand, Stdio};
917 use std::thread;
918 use wait_timeout::ChildExt;
919
920 let mut cmd = StdCommand::new(binary);
921 cmd.args(args);
922 cmd.stdin(Stdio::null());
923 cmd.stdout(Stdio::piped());
924 cmd.stderr(Stdio::piped());
925 cmd.env_remove("CLAUDECODE");
926 cmd.env_remove("CLAUDE_CODE_ENTRYPOINT");
927
928 if let Some(dir) = working_dir {
929 cmd.current_dir(dir);
930 }
931
932 for (key, value) in env {
933 cmd.env(key, value);
934 }
935
936 let mut child = spawn_retrying_txtbsy_sync(&mut cmd).map_err(|e| Error::Io {
937 message: format!("failed to spawn claude: {e}"),
938 source: e,
939 working_dir: working_dir.map(|p| p.to_path_buf()),
940 })?;
941
942 let stdout = child.stdout.take().expect("stdout was piped");
947 let stderr = child.stderr.take().expect("stderr was piped");
948
949 let stdout_thread = thread::spawn(move || drain_sync(stdout));
950 let stderr_thread = thread::spawn(move || drain_sync(stderr));
951
952 match child.wait_timeout(timeout).map_err(|e| Error::Io {
953 message: "failed to wait for claude process".to_string(),
954 source: e,
955 working_dir: working_dir.map(|p| p.to_path_buf()),
956 })? {
957 Some(status) => {
958 let stdout = stdout_thread.join().unwrap_or_default();
959 let stderr = stderr_thread.join().unwrap_or_default();
960 let exit_code = status.code().unwrap_or(-1);
961
962 if !status.success() {
963 return Err(Error::from_command_failure(
964 format!("{} {}", binary.display(), args.join(" ")),
965 exit_code,
966 stdout,
967 stderr,
968 working_dir.map(|p| p.to_path_buf()),
969 ));
970 }
971
972 Ok(CommandOutput {
973 stdout,
974 stderr,
975 exit_code,
976 success: true,
977 })
978 }
979 None => {
980 let _ = child.kill();
985 let _ = child.wait();
986
987 let (stdout_str, stderr_str) =
988 join_with_deadline(stdout_thread, stderr_thread, Duration::from_millis(200));
989
990 if !stdout_str.is_empty() || !stderr_str.is_empty() {
991 warn!(
992 stdout = %stdout_str,
993 stderr = %stderr_str,
994 "partial output from timed-out process",
995 );
996 }
997
998 Err(Error::Timeout {
999 timeout_seconds: timeout.as_secs(),
1000 })
1001 }
1002 }
1003}
1004
1005#[cfg(feature = "sync")]
1006fn drain_sync<R: std::io::Read>(mut reader: R) -> String {
1007 let mut buf = Vec::new();
1008 let _ = reader.read_to_end(&mut buf);
1009 String::from_utf8_lossy(&buf).into_owned()
1010}
1011
1012#[cfg(feature = "sync")]
1015fn spawn_retrying_txtbsy_sync(
1016 cmd: &mut std::process::Command,
1017) -> std::io::Result<std::process::Child> {
1018 let mut backoff = Duration::from_millis(1);
1019 loop {
1020 match cmd.spawn() {
1021 Err(e)
1022 if e.kind() == std::io::ErrorKind::ExecutableFileBusy
1023 && backoff <= TXTBSY_RETRY_BUDGET =>
1024 {
1025 std::thread::sleep(backoff);
1026 backoff *= 2;
1027 }
1028 other => return other,
1029 }
1030 }
1031}
1032
1033#[cfg(feature = "sync")]
1039fn join_with_deadline(
1040 stdout_thread: std::thread::JoinHandle<String>,
1041 stderr_thread: std::thread::JoinHandle<String>,
1042 budget: Duration,
1043) -> (String, String) {
1044 use std::sync::mpsc;
1045 use std::thread;
1046
1047 let (tx, rx) = mpsc::channel::<(&'static str, String)>();
1048
1049 let tx_out = tx.clone();
1050 let tx_err = tx;
1051
1052 thread::spawn(move || {
1053 let s = stdout_thread.join().unwrap_or_default();
1054 let _ = tx_out.send(("stdout", s));
1055 });
1056 thread::spawn(move || {
1057 let s = stderr_thread.join().unwrap_or_default();
1058 let _ = tx_err.send(("stderr", s));
1059 });
1060
1061 let mut stdout = String::new();
1062 let mut stderr = String::new();
1063 let deadline = std::time::Instant::now() + budget;
1064
1065 for _ in 0..2 {
1066 let now = std::time::Instant::now();
1067 if now >= deadline {
1068 break;
1069 }
1070 match rx.recv_timeout(deadline - now) {
1071 Ok(("stdout", s)) => stdout = s,
1072 Ok(("stderr", s)) => stderr = s,
1073 Ok(_) => unreachable!(),
1074 Err(_) => break,
1075 }
1076 }
1077
1078 (stdout, stderr)
1079}
1080
1081#[cfg(all(test, unix, any(feature = "async", feature = "sync")))]
1088mod tests {
1089 use super::*;
1090 use std::io::Write;
1091 use std::os::unix::fs::PermissionsExt;
1092
1093 use crate::Claude;
1094
1095 fn fake_script(body: &str) -> (tempfile::TempDir, std::path::PathBuf) {
1099 let dir = tempfile::tempdir().expect("tempdir");
1100 let path = dir.path().join("fake-claude.sh");
1101 {
1106 let mut f = std::fs::File::create(&path).expect("create script");
1107 write!(f, "#!/usr/bin/env bash\n{body}\n").expect("write script");
1108 f.sync_all().expect("sync script");
1109 }
1110 let perms = std::fs::Permissions::from_mode(0o755);
1111 std::fs::set_permissions(&path, perms).expect("chmod");
1112 (dir, path)
1113 }
1114
1115 fn client(path: &std::path::Path) -> Claude {
1116 Claude::builder()
1117 .binary(path)
1118 .build()
1119 .expect("build client")
1120 }
1121
1122 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1124
1125 fn set_scrub_vars() {
1126 let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1127 unsafe {
1130 std::env::set_var("CLAUDECODE", "1");
1131 std::env::set_var("CLAUDE_CODE_ENTRYPOINT", "cli");
1132 }
1133 }
1134
1135 fn clear_scrub_vars() {
1136 let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1137 unsafe {
1139 std::env::remove_var("CLAUDECODE");
1140 std::env::remove_var("CLAUDE_CODE_ENTRYPOINT");
1141 }
1142 }
1143
1144 #[cfg(feature = "async")]
1147 #[tokio::test]
1148 async fn async_success_maps_output() {
1149 let (_dir, path) = fake_script(r#"echo "hi there"; exit 0"#);
1150 let out = run_claude(&client(&path), vec!["--version".into()])
1151 .await
1152 .expect("success");
1153 assert!(out.success);
1154 assert_eq!(out.exit_code, 0);
1155 assert!(out.stdout.contains("hi there"));
1156 }
1157
1158 #[cfg(feature = "async")]
1159 #[tokio::test]
1160 async fn async_nonzero_exit_maps_command_failed() {
1161 let (_dir, path) = fake_script(r#"echo "boom" >&2; exit 3"#);
1162 let err = run_claude(&client(&path), vec![]).await.unwrap_err();
1163 match err {
1164 Error::CommandFailed {
1165 exit_code, stderr, ..
1166 } => {
1167 assert_eq!(exit_code, 3);
1168 assert!(stderr.contains("boom"));
1169 }
1170 other => panic!("expected CommandFailed, got {other:?}"),
1171 }
1172 }
1173
1174 #[cfg(feature = "async")]
1175 #[tokio::test]
1176 async fn async_rail_stop_maps_max_turns() {
1177 let (_dir, path) = fake_script(
1178 r#"echo '{"type":"result","subtype":"error_max_turns","is_error":true,"errors":["Reached maximum number of turns (2)"]}'; exit 1"#,
1179 );
1180 let err = run_claude(&client(&path), vec![]).await.unwrap_err();
1181 assert!(
1182 matches!(
1183 err,
1184 Error::MaxTurnsExceeded {
1185 max_turns: Some(2),
1186 ..
1187 }
1188 ),
1189 "got: {err:?}"
1190 );
1191 }
1192
1193 #[cfg(feature = "async")]
1194 #[tokio::test]
1195 async fn async_auth_shaped_stderr_maps_auth() {
1196 let (_dir, path) =
1197 fake_script(r#"echo "Not authenticated. Run `claude login`." >&2; exit 1"#);
1198 let err = run_claude(&client(&path), vec![]).await.unwrap_err();
1199 assert!(matches!(err, Error::Auth { .. }), "got: {err:?}");
1200 }
1201
1202 #[cfg(feature = "async")]
1203 #[tokio::test]
1204 async fn async_scrubs_claude_env_vars() {
1205 let (_dir, path) =
1206 fake_script(r#"echo "CC=[${CLAUDECODE:-}] EP=[${CLAUDE_CODE_ENTRYPOINT:-}]""#);
1207 set_scrub_vars();
1213 let out = run_claude(&client(&path), vec![]).await.expect("success");
1214 clear_scrub_vars();
1215 assert!(out.stdout.contains("CC=[]"), "got: {}", out.stdout);
1216 assert!(out.stdout.contains("EP=[]"), "got: {}", out.stdout);
1217 }
1218
1219 #[cfg(feature = "async")]
1220 #[tokio::test]
1221 async fn async_applies_working_dir() {
1222 let (_dir, path) = fake_script(r#"pwd"#);
1223 let workdir = tempfile::tempdir().expect("workdir");
1224 let claude = Claude::builder()
1225 .binary(&path)
1226 .working_dir(workdir.path())
1227 .build()
1228 .expect("build");
1229 let out = run_claude(&claude, vec![]).await.expect("success");
1230 let got = std::fs::canonicalize(out.stdout.trim()).expect("canonicalize pwd");
1231 let want = std::fs::canonicalize(workdir.path()).expect("canonicalize workdir");
1232 assert_eq!(got, want);
1233 }
1234
1235 #[cfg(feature = "async")]
1236 #[tokio::test]
1237 async fn async_stdin_prompt_round_trips() {
1238 let (_dir, path) = fake_script(r#"cat"#);
1239 let out = run_claude_with_stdin_prompt(&client(&path), vec![], "hello via stdin".into())
1240 .await
1241 .expect("success");
1242 assert!(out.stdout.contains("hello via stdin"));
1243 }
1244
1245 #[cfg(feature = "async")]
1250 #[tokio::test]
1251 async fn async_spawn_retry_passes_through_non_txtbsy_error() {
1252 let mut cmd = Command::new("/nonexistent/definitely-not-a-real-binary");
1253 let err = spawn_retrying_txtbsy(&mut cmd)
1254 .await
1255 .expect_err("spawn of missing binary should fail");
1256 assert_eq!(err.kind(), std::io::ErrorKind::NotFound, "got: {err:?}");
1257 }
1258
1259 #[cfg(feature = "async")]
1260 #[tokio::test]
1261 async fn async_allow_exit_codes_permits_listed_code() {
1262 let (_dir, path) = fake_script(r#"echo out; exit 2"#);
1263 let out = run_claude_allow_exit_codes(&client(&path), vec![], &[2])
1264 .await
1265 .expect("allowed code is Ok");
1266 assert!(!out.success);
1267 assert_eq!(out.exit_code, 2);
1268 assert!(out.stdout.contains("out"));
1269 }
1270
1271 #[cfg(feature = "async")]
1272 #[tokio::test]
1273 async fn async_allow_exit_codes_still_errors_on_unlisted_code() {
1274 let (_dir, path) = fake_script(r#"exit 2"#);
1275 let err = run_claude_allow_exit_codes(&client(&path), vec![], &[5])
1276 .await
1277 .unwrap_err();
1278 assert!(
1279 matches!(err, Error::CommandFailed { exit_code: 2, .. }),
1280 "got: {err:?}"
1281 );
1282 }
1283
1284 #[cfg(feature = "async")]
1285 #[tokio::test]
1286 async fn async_timeout_fires_on_slow_child() {
1287 let (_dir, path) = fake_script(r#"sleep 3; echo done"#);
1288 let claude = Claude::builder()
1289 .binary(&path)
1290 .timeout(Duration::from_millis(300))
1291 .build()
1292 .expect("build");
1293 let err = run_claude(&claude, vec![]).await.unwrap_err();
1294 assert!(matches!(err, Error::Timeout { .. }), "got: {err:?}");
1295 }
1296
1297 #[cfg(feature = "async")]
1298 #[tokio::test]
1299 async fn async_timeout_path_returns_output_when_fast() {
1300 let (_dir, path) = fake_script(r#"echo quick"#);
1301 let claude = Claude::builder()
1302 .binary(&path)
1303 .timeout(Duration::from_secs(30))
1304 .build()
1305 .expect("build");
1306 let out = run_claude(&claude, vec![]).await.expect("success");
1307 assert!(out.stdout.contains("quick"));
1308 }
1309
1310 #[cfg(feature = "async")]
1311 #[tokio::test]
1312 async fn async_timeout_path_maps_command_failed() {
1313 let (_dir, path) = fake_script(r#"echo e >&2; exit 4"#);
1314 let claude = Claude::builder()
1315 .binary(&path)
1316 .timeout(Duration::from_secs(30))
1317 .build()
1318 .expect("build");
1319 let err = run_claude(&claude, vec![]).await.unwrap_err();
1320 assert!(
1321 matches!(err, Error::CommandFailed { exit_code: 4, .. }),
1322 "got: {err:?}"
1323 );
1324 }
1325
1326 #[cfg(feature = "async")]
1327 #[tokio::test]
1328 async fn async_stdin_with_timeout_round_trips() {
1329 let (_dir, path) = fake_script(r#"cat"#);
1330 let claude = Claude::builder()
1331 .binary(&path)
1332 .timeout(Duration::from_secs(30))
1333 .build()
1334 .expect("build");
1335 let out = run_claude_with_stdin_prompt(&claude, vec![], "piped under timeout".into())
1336 .await
1337 .expect("success");
1338 assert!(out.stdout.contains("piped under timeout"));
1339 }
1340
1341 #[cfg(feature = "async")]
1342 #[tokio::test]
1343 async fn async_stdin_timeout_fires_on_slow_child() {
1344 let (_dir, path) = fake_script(r#"sleep 3"#);
1345 let claude = Claude::builder()
1346 .binary(&path)
1347 .timeout(Duration::from_millis(300))
1348 .build()
1349 .expect("build");
1350 let err = run_claude_with_stdin_prompt(&claude, vec![], "x".into())
1351 .await
1352 .unwrap_err();
1353 assert!(matches!(err, Error::Timeout { .. }), "got: {err:?}");
1354 }
1355
1356 #[cfg(feature = "async")]
1357 #[tokio::test]
1358 async fn async_spawn_failure_maps_io() {
1359 let claude = Claude::builder()
1360 .binary("/nonexistent/definitely/not/here")
1361 .build()
1362 .expect("build");
1363 let err = run_claude(&claude, vec![]).await.unwrap_err();
1364 assert!(matches!(err, Error::Io { .. }), "got: {err:?}");
1365 }
1366
1367 #[cfg(feature = "sync")]
1370 #[test]
1371 fn sync_success_maps_output() {
1372 let (_dir, path) = fake_script(r#"echo "hi sync"; exit 0"#);
1373 let out = run_claude_sync(&client(&path), vec![]).expect("success");
1374 assert!(out.success);
1375 assert!(out.stdout.contains("hi sync"));
1376 }
1377
1378 #[cfg(feature = "sync")]
1379 #[test]
1380 fn sync_nonzero_exit_maps_command_failed() {
1381 let (_dir, path) = fake_script(r#"echo "boom" >&2; exit 3"#);
1382 let err = run_claude_sync(&client(&path), vec![]).unwrap_err();
1383 match err {
1384 Error::CommandFailed {
1385 exit_code, stderr, ..
1386 } => {
1387 assert_eq!(exit_code, 3);
1388 assert!(stderr.contains("boom"));
1389 }
1390 other => panic!("expected CommandFailed, got {other:?}"),
1391 }
1392 }
1393
1394 #[cfg(feature = "sync")]
1395 #[test]
1396 fn sync_scrubs_claude_env_vars() {
1397 let (_dir, path) =
1398 fake_script(r#"echo "CC=[${CLAUDECODE:-}] EP=[${CLAUDE_CODE_ENTRYPOINT:-}]""#);
1399 set_scrub_vars();
1400 let out = run_claude_sync(&client(&path), vec![]).expect("success");
1401 clear_scrub_vars();
1402 assert!(out.stdout.contains("CC=[]"), "got: {}", out.stdout);
1403 assert!(out.stdout.contains("EP=[]"), "got: {}", out.stdout);
1404 }
1405
1406 #[cfg(feature = "sync")]
1407 #[test]
1408 fn sync_stdin_prompt_round_trips() {
1409 let (_dir, path) = fake_script(r#"cat"#);
1410 let out = run_claude_with_stdin_prompt_sync(&client(&path), vec![], "sync stdin".into())
1411 .expect("success");
1412 assert!(out.stdout.contains("sync stdin"));
1413 }
1414
1415 #[cfg(feature = "sync")]
1418 #[test]
1419 fn sync_spawn_retry_passes_through_non_txtbsy_error() {
1420 let mut cmd = std::process::Command::new("/nonexistent/definitely-not-a-real-binary");
1421 let err =
1422 spawn_retrying_txtbsy_sync(&mut cmd).expect_err("spawn of missing binary should fail");
1423 assert_eq!(err.kind(), std::io::ErrorKind::NotFound, "got: {err:?}");
1424 }
1425
1426 #[cfg(feature = "sync")]
1427 #[test]
1428 fn sync_allow_exit_codes_permits_listed_code() {
1429 let (_dir, path) = fake_script(r#"echo out; exit 2"#);
1430 let out = run_claude_allow_exit_codes_sync(&client(&path), vec![], &[2])
1431 .expect("allowed code is Ok");
1432 assert!(!out.success);
1433 assert_eq!(out.exit_code, 2);
1434 }
1435
1436 #[cfg(feature = "sync")]
1437 #[test]
1438 fn sync_timeout_fires_on_slow_child() {
1439 let (_dir, path) = fake_script(r#"sleep 3; echo done"#);
1440 let claude = Claude::builder()
1441 .binary(&path)
1442 .timeout(Duration::from_millis(300))
1443 .build()
1444 .expect("build");
1445 let err = run_claude_sync(&claude, vec![]).unwrap_err();
1446 assert!(matches!(err, Error::Timeout { .. }), "got: {err:?}");
1447 }
1448
1449 #[cfg(feature = "sync")]
1450 #[test]
1451 fn sync_timeout_path_returns_output_when_fast() {
1452 let (_dir, path) = fake_script(r#"echo quick"#);
1453 let claude = Claude::builder()
1454 .binary(&path)
1455 .timeout(Duration::from_secs(30))
1456 .build()
1457 .expect("build");
1458 let out = run_claude_sync(&claude, vec![]).expect("success");
1459 assert!(out.stdout.contains("quick"));
1460 }
1461
1462 #[cfg(feature = "sync")]
1463 #[test]
1464 fn sync_stdin_with_timeout_round_trips() {
1465 let (_dir, path) = fake_script(r#"cat"#);
1466 let claude = Claude::builder()
1467 .binary(&path)
1468 .timeout(Duration::from_secs(30))
1469 .build()
1470 .expect("build");
1471 let out = run_claude_with_stdin_prompt_sync(&claude, vec![], "sync piped".into())
1472 .expect("success");
1473 assert!(out.stdout.contains("sync piped"));
1474 }
1475}