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 = cmd.spawn().map_err(|e| Error::Io {
134 message: format!("failed to spawn claude: {e}"),
135 source: e,
136 working_dir: working_dir.map(|p| p.to_path_buf()),
137 })?;
138
139 if let Some(mut stdin) = child.stdin.take() {
141 stdin
142 .write_all(stdin_content.as_bytes())
143 .await
144 .map_err(|e| Error::Io {
145 message: format!("failed to write to claude stdin: {e}"),
146 source: e,
147 working_dir: working_dir.map(|p| p.to_path_buf()),
148 })?;
149 }
151
152 let mut stdout_handle = child.stdout.take().expect("stdout was piped");
153 let mut stderr_handle = child.stderr.take().expect("stderr was piped");
154
155 let (status, stdout_str, stderr_str) = tokio::join!(
156 child.wait(),
157 drain(&mut stdout_handle),
158 drain(&mut stderr_handle),
159 );
160
161 let status = status.map_err(|e| Error::Io {
162 message: "failed to wait for claude process".to_string(),
163 source: e,
164 working_dir: working_dir.map(|p| p.to_path_buf()),
165 })?;
166
167 let exit_code = status.code().unwrap_or(-1);
168
169 if !status.success() {
170 return Err(Error::from_command_failure(
171 format!("{} {}", binary.display(), args.join(" ")),
172 exit_code,
173 stdout_str,
174 stderr_str,
175 working_dir.map(|p| p.to_path_buf()),
176 ));
177 }
178
179 Ok(CommandOutput {
180 stdout: stdout_str,
181 stderr: stderr_str,
182 exit_code,
183 success: true,
184 })
185}
186
187#[cfg(feature = "async")]
188async fn run_with_timeout_stdin(
189 binary: &std::path::Path,
190 args: &[String],
191 env: &std::collections::HashMap<String, String>,
192 working_dir: Option<&std::path::Path>,
193 timeout: Duration,
194 stdin_content: String,
195) -> Result<CommandOutput> {
196 use tokio::io::AsyncWriteExt;
197
198 let mut cmd = Command::new(binary);
199 cmd.args(args);
200 cmd.stdin(std::process::Stdio::piped());
201 cmd.stdout(std::process::Stdio::piped());
202 cmd.stderr(std::process::Stdio::piped());
203 cmd.env_remove("CLAUDECODE");
204 cmd.env_remove("CLAUDE_CODE_ENTRYPOINT");
205
206 if let Some(dir) = working_dir {
207 cmd.current_dir(dir);
208 }
209
210 for (key, value) in env {
211 cmd.env(key, value);
212 }
213
214 let mut child = cmd.spawn().map_err(|e| Error::Io {
215 message: format!("failed to spawn claude: {e}"),
216 source: e,
217 working_dir: working_dir.map(|p| p.to_path_buf()),
218 })?;
219
220 if let Some(mut stdin) = child.stdin.take() {
222 stdin
223 .write_all(stdin_content.as_bytes())
224 .await
225 .map_err(|e| Error::Io {
226 message: format!("failed to write to claude stdin: {e}"),
227 source: e,
228 working_dir: working_dir.map(|p| p.to_path_buf()),
229 })?;
230 }
232
233 let mut stdout_handle = child.stdout.take().expect("stdout was piped");
234 let mut stderr_handle = child.stderr.take().expect("stderr was piped");
235
236 let wait_and_drain = async {
237 let (status, stdout_str, stderr_str) = tokio::join!(
238 child.wait(),
239 drain(&mut stdout_handle),
240 drain(&mut stderr_handle),
241 );
242 (status, stdout_str, stderr_str)
243 };
244
245 match tokio::time::timeout(timeout, wait_and_drain).await {
246 Ok((Ok(status), stdout, stderr)) => {
247 let exit_code = status.code().unwrap_or(-1);
248
249 if !status.success() {
250 return Err(Error::from_command_failure(
251 format!("{} {}", binary.display(), args.join(" ")),
252 exit_code,
253 stdout,
254 stderr,
255 working_dir.map(|p| p.to_path_buf()),
256 ));
257 }
258
259 Ok(CommandOutput {
260 stdout,
261 stderr,
262 exit_code,
263 success: true,
264 })
265 }
266 Ok((Err(e), _stdout, _stderr)) => Err(Error::Io {
267 message: "failed to wait for claude process".to_string(),
268 source: e,
269 working_dir: working_dir.map(|p| p.to_path_buf()),
270 }),
271 Err(_) => {
272 let _ = child.kill().await;
273 let drain_budget = Duration::from_millis(200);
274 let stdout_str = tokio::time::timeout(drain_budget, drain(&mut stdout_handle))
275 .await
276 .unwrap_or_default();
277 let stderr_str = tokio::time::timeout(drain_budget, drain(&mut stderr_handle))
278 .await
279 .unwrap_or_default();
280 if !stdout_str.is_empty() || !stderr_str.is_empty() {
281 warn!(
282 stdout = %stdout_str,
283 stderr = %stderr_str,
284 "partial output from timed-out process",
285 );
286 }
287 Err(Error::Timeout {
288 timeout_seconds: timeout.as_secs(),
289 })
290 }
291 }
292}
293
294#[cfg(feature = "async")]
295async fn run_claude_once(claude: &Claude, args: Vec<String>) -> Result<CommandOutput> {
296 let mut command_args = Vec::new();
297
298 command_args.extend(claude.global_args.clone());
300
301 command_args.extend(args);
303
304 debug!(binary = %claude.binary.display(), args = ?command_args, "executing claude command");
305
306 let output = if let Some(timeout) = claude.timeout {
307 run_with_timeout(
308 &claude.binary,
309 &command_args,
310 &claude.env,
311 claude.working_dir.as_deref(),
312 timeout,
313 )
314 .await?
315 } else {
316 run_internal(
317 &claude.binary,
318 &command_args,
319 &claude.env,
320 claude.working_dir.as_deref(),
321 )
322 .await?
323 };
324
325 Ok(output)
326}
327
328#[cfg(feature = "async")]
330pub async fn run_claude_allow_exit_codes(
331 claude: &Claude,
332 args: Vec<String>,
333 allowed_codes: &[i32],
334) -> Result<CommandOutput> {
335 let output = run_claude(claude, args).await;
336
337 match output {
338 Err(Error::CommandFailed {
339 exit_code,
340 stdout,
341 stderr,
342 ..
343 }) if allowed_codes.contains(&exit_code) => Ok(CommandOutput {
344 stdout,
345 stderr,
346 exit_code,
347 success: false,
348 }),
349 other => other,
350 }
351}
352
353#[cfg(feature = "async")]
354async fn run_internal(
355 binary: &std::path::Path,
356 args: &[String],
357 env: &std::collections::HashMap<String, String>,
358 working_dir: Option<&std::path::Path>,
359) -> Result<CommandOutput> {
360 let mut cmd = Command::new(binary);
361 cmd.args(args);
362
363 cmd.stdin(std::process::Stdio::null());
365
366 cmd.env_remove("CLAUDECODE");
368 cmd.env_remove("CLAUDE_CODE_ENTRYPOINT");
369
370 if let Some(dir) = working_dir {
371 cmd.current_dir(dir);
372 }
373
374 for (key, value) in env {
375 cmd.env(key, value);
376 }
377
378 let output = cmd.output().await.map_err(|e| Error::Io {
379 message: format!("failed to spawn claude: {e}"),
380 source: e,
381 working_dir: working_dir.map(|p| p.to_path_buf()),
382 })?;
383
384 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
385 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
386 let exit_code = output.status.code().unwrap_or(-1);
387
388 if !output.status.success() {
389 return Err(Error::from_command_failure(
390 format!("{} {}", binary.display(), args.join(" ")),
391 exit_code,
392 stdout,
393 stderr,
394 working_dir.map(|p| p.to_path_buf()),
395 ));
396 }
397
398 Ok(CommandOutput {
399 stdout,
400 stderr,
401 exit_code,
402 success: true,
403 })
404}
405
406#[cfg(feature = "async")]
418async fn run_with_timeout(
419 binary: &std::path::Path,
420 args: &[String],
421 env: &std::collections::HashMap<String, String>,
422 working_dir: Option<&std::path::Path>,
423 timeout: Duration,
424) -> Result<CommandOutput> {
425 let mut cmd = Command::new(binary);
426 cmd.args(args);
427 cmd.stdin(std::process::Stdio::null());
428 cmd.stdout(std::process::Stdio::piped());
429 cmd.stderr(std::process::Stdio::piped());
430 cmd.env_remove("CLAUDECODE");
431 cmd.env_remove("CLAUDE_CODE_ENTRYPOINT");
432
433 if let Some(dir) = working_dir {
434 cmd.current_dir(dir);
435 }
436
437 for (key, value) in env {
438 cmd.env(key, value);
439 }
440
441 let mut child = cmd.spawn().map_err(|e| Error::Io {
442 message: format!("failed to spawn claude: {e}"),
443 source: e,
444 working_dir: working_dir.map(|p| p.to_path_buf()),
445 })?;
446
447 let mut stdout = child.stdout.take().expect("stdout was piped");
448 let mut stderr = child.stderr.take().expect("stderr was piped");
449
450 let wait_and_drain = async {
455 let (status, stdout_str, stderr_str) =
456 tokio::join!(child.wait(), drain(&mut stdout), drain(&mut stderr));
457 (status, stdout_str, stderr_str)
458 };
459
460 match tokio::time::timeout(timeout, wait_and_drain).await {
461 Ok((Ok(status), stdout, stderr)) => {
462 let exit_code = status.code().unwrap_or(-1);
463
464 if !status.success() {
465 return Err(Error::from_command_failure(
466 format!("{} {}", binary.display(), args.join(" ")),
467 exit_code,
468 stdout,
469 stderr,
470 working_dir.map(|p| p.to_path_buf()),
471 ));
472 }
473
474 Ok(CommandOutput {
475 stdout,
476 stderr,
477 exit_code,
478 success: true,
479 })
480 }
481 Ok((Err(e), _stdout, _stderr)) => Err(Error::Io {
482 message: "failed to wait for claude process".to_string(),
483 source: e,
484 working_dir: working_dir.map(|p| p.to_path_buf()),
485 }),
486 Err(_) => {
487 let _ = child.kill().await;
493 let drain_budget = Duration::from_millis(200);
494 let stdout_str = tokio::time::timeout(drain_budget, drain(&mut stdout))
495 .await
496 .unwrap_or_default();
497 let stderr_str = tokio::time::timeout(drain_budget, drain(&mut stderr))
498 .await
499 .unwrap_or_default();
500 if !stdout_str.is_empty() || !stderr_str.is_empty() {
501 warn!(
502 stdout = %stdout_str,
503 stderr = %stderr_str,
504 "partial output from timed-out process",
505 );
506 }
507 Err(Error::Timeout {
508 timeout_seconds: timeout.as_secs(),
509 })
510 }
511 }
512}
513
514#[cfg(feature = "async")]
515async fn drain<R: AsyncReadExt + Unpin>(reader: &mut R) -> String {
516 let mut buf = Vec::new();
517 let _ = reader.read_to_end(&mut buf).await;
518 String::from_utf8_lossy(&buf).into_owned()
519}
520
521#[cfg(feature = "sync")]
525pub fn run_claude_sync(claude: &Claude, args: Vec<String>) -> Result<CommandOutput> {
526 run_claude_with_retry_sync(claude, args, None)
527}
528
529#[cfg(feature = "sync")]
531pub fn run_claude_with_retry_sync(
532 claude: &Claude,
533 args: Vec<String>,
534 retry_override: Option<&crate::retry::RetryPolicy>,
535) -> Result<CommandOutput> {
536 let policy = retry_override.or(claude.retry_policy.as_ref());
537
538 match policy {
539 Some(policy) => {
540 crate::retry::with_retry_sync(policy, || run_claude_once_sync(claude, args.clone()))
541 }
542 None => run_claude_once_sync(claude, args),
543 }
544}
545
546#[cfg(feature = "sync")]
551pub fn run_claude_with_stdin_prompt_sync(
552 claude: &Claude,
553 args: Vec<String>,
554 stdin_content: String,
555) -> Result<CommandOutput> {
556 let mut command_args = Vec::new();
557 command_args.extend(claude.global_args.clone());
558 command_args.extend(args);
559
560 debug!(binary = %claude.binary.display(), args = ?command_args, "executing claude command (stdin prompt, sync)");
561
562 if let Some(timeout) = claude.timeout {
563 run_with_timeout_stdin_sync(
564 &claude.binary,
565 &command_args,
566 &claude.env,
567 claude.working_dir.as_deref(),
568 timeout,
569 stdin_content,
570 )
571 } else {
572 run_internal_stdin_sync(
573 &claude.binary,
574 &command_args,
575 &claude.env,
576 claude.working_dir.as_deref(),
577 stdin_content,
578 )
579 }
580}
581
582#[cfg(feature = "sync")]
583fn run_internal_stdin_sync(
584 binary: &std::path::Path,
585 args: &[String],
586 env: &std::collections::HashMap<String, String>,
587 working_dir: Option<&std::path::Path>,
588 stdin_content: String,
589) -> Result<CommandOutput> {
590 use std::io::Write;
591 use std::process::{Command as StdCommand, Stdio};
592
593 let mut cmd = StdCommand::new(binary);
594 cmd.args(args);
595 cmd.stdin(Stdio::piped());
596 cmd.stdout(Stdio::piped());
597 cmd.stderr(Stdio::piped());
598 cmd.env_remove("CLAUDECODE");
599 cmd.env_remove("CLAUDE_CODE_ENTRYPOINT");
600
601 if let Some(dir) = working_dir {
602 cmd.current_dir(dir);
603 }
604
605 for (key, value) in env {
606 cmd.env(key, value);
607 }
608
609 let mut child = cmd.spawn().map_err(|e| Error::Io {
610 message: format!("failed to spawn claude: {e}"),
611 source: e,
612 working_dir: working_dir.map(|p| p.to_path_buf()),
613 })?;
614
615 if let Some(mut stdin) = child.stdin.take() {
617 stdin
618 .write_all(stdin_content.as_bytes())
619 .map_err(|e| Error::Io {
620 message: format!("failed to write to claude stdin: {e}"),
621 source: e,
622 working_dir: working_dir.map(|p| p.to_path_buf()),
623 })?;
624 stdin.flush().map_err(|e| Error::Io {
625 message: format!("failed to flush claude stdin: {e}"),
626 source: e,
627 working_dir: working_dir.map(|p| p.to_path_buf()),
628 })?;
629 }
631
632 let output = child.wait_with_output().map_err(|e| Error::Io {
633 message: "failed to wait for claude process".to_string(),
634 source: e,
635 working_dir: working_dir.map(|p| p.to_path_buf()),
636 })?;
637
638 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
639 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
640 let exit_code = output.status.code().unwrap_or(-1);
641
642 if !output.status.success() {
643 return Err(Error::from_command_failure(
644 format!("{} {}", binary.display(), args.join(" ")),
645 exit_code,
646 stdout,
647 stderr,
648 working_dir.map(|p| p.to_path_buf()),
649 ));
650 }
651
652 Ok(CommandOutput {
653 stdout,
654 stderr,
655 exit_code,
656 success: true,
657 })
658}
659
660#[cfg(feature = "sync")]
661fn run_with_timeout_stdin_sync(
662 binary: &std::path::Path,
663 args: &[String],
664 env: &std::collections::HashMap<String, String>,
665 working_dir: Option<&std::path::Path>,
666 timeout: Duration,
667 stdin_content: String,
668) -> Result<CommandOutput> {
669 use std::io::Write;
670 use std::process::{Command as StdCommand, Stdio};
671 use std::thread;
672 use wait_timeout::ChildExt;
673
674 let mut cmd = StdCommand::new(binary);
675 cmd.args(args);
676 cmd.stdin(Stdio::piped());
677 cmd.stdout(Stdio::piped());
678 cmd.stderr(Stdio::piped());
679 cmd.env_remove("CLAUDECODE");
680 cmd.env_remove("CLAUDE_CODE_ENTRYPOINT");
681
682 if let Some(dir) = working_dir {
683 cmd.current_dir(dir);
684 }
685
686 for (key, value) in env {
687 cmd.env(key, value);
688 }
689
690 let mut child = cmd.spawn().map_err(|e| Error::Io {
691 message: format!("failed to spawn claude: {e}"),
692 source: e,
693 working_dir: working_dir.map(|p| p.to_path_buf()),
694 })?;
695
696 if let Some(mut stdin) = child.stdin.take() {
698 stdin
699 .write_all(stdin_content.as_bytes())
700 .map_err(|e| Error::Io {
701 message: format!("failed to write to claude stdin: {e}"),
702 source: e,
703 working_dir: working_dir.map(|p| p.to_path_buf()),
704 })?;
705 stdin.flush().map_err(|e| Error::Io {
706 message: format!("failed to flush claude stdin: {e}"),
707 source: e,
708 working_dir: working_dir.map(|p| p.to_path_buf()),
709 })?;
710 }
712
713 let stdout = child.stdout.take().expect("stdout was piped");
714 let stderr = child.stderr.take().expect("stderr was piped");
715
716 let stdout_thread = thread::spawn(move || drain_sync(stdout));
717 let stderr_thread = thread::spawn(move || drain_sync(stderr));
718
719 match child.wait_timeout(timeout).map_err(|e| Error::Io {
720 message: "failed to wait for claude process".to_string(),
721 source: e,
722 working_dir: working_dir.map(|p| p.to_path_buf()),
723 })? {
724 Some(status) => {
725 let stdout = stdout_thread.join().unwrap_or_default();
726 let stderr = stderr_thread.join().unwrap_or_default();
727 let exit_code = status.code().unwrap_or(-1);
728
729 if !status.success() {
730 return Err(Error::from_command_failure(
731 format!("{} {}", binary.display(), args.join(" ")),
732 exit_code,
733 stdout,
734 stderr,
735 working_dir.map(|p| p.to_path_buf()),
736 ));
737 }
738
739 Ok(CommandOutput {
740 stdout,
741 stderr,
742 exit_code,
743 success: true,
744 })
745 }
746 None => {
747 let _ = child.kill();
748 let _ = child.wait();
749 let (stdout_str, stderr_str) =
750 join_with_deadline(stdout_thread, stderr_thread, Duration::from_millis(200));
751 if !stdout_str.is_empty() || !stderr_str.is_empty() {
752 warn!(
753 stdout = %stdout_str,
754 stderr = %stderr_str,
755 "partial output from timed-out process",
756 );
757 }
758 Err(Error::Timeout {
759 timeout_seconds: timeout.as_secs(),
760 })
761 }
762 }
763}
764
765#[cfg(feature = "sync")]
766fn run_claude_once_sync(claude: &Claude, args: Vec<String>) -> Result<CommandOutput> {
767 let mut command_args = Vec::new();
768 command_args.extend(claude.global_args.clone());
769 command_args.extend(args);
770
771 debug!(binary = %claude.binary.display(), args = ?command_args, "executing claude command (sync)");
772
773 if let Some(timeout) = claude.timeout {
774 run_with_timeout_sync(
775 &claude.binary,
776 &command_args,
777 &claude.env,
778 claude.working_dir.as_deref(),
779 timeout,
780 )
781 } else {
782 run_internal_sync(
783 &claude.binary,
784 &command_args,
785 &claude.env,
786 claude.working_dir.as_deref(),
787 )
788 }
789}
790
791#[cfg(feature = "sync")]
793pub fn run_claude_allow_exit_codes_sync(
794 claude: &Claude,
795 args: Vec<String>,
796 allowed_codes: &[i32],
797) -> Result<CommandOutput> {
798 match run_claude_sync(claude, args) {
799 Err(Error::CommandFailed {
800 exit_code,
801 stdout,
802 stderr,
803 ..
804 }) if allowed_codes.contains(&exit_code) => Ok(CommandOutput {
805 stdout,
806 stderr,
807 exit_code,
808 success: false,
809 }),
810 other => other,
811 }
812}
813
814#[cfg(feature = "sync")]
815fn run_internal_sync(
816 binary: &std::path::Path,
817 args: &[String],
818 env: &std::collections::HashMap<String, String>,
819 working_dir: Option<&std::path::Path>,
820) -> Result<CommandOutput> {
821 use std::process::{Command as StdCommand, Stdio};
822
823 let mut cmd = StdCommand::new(binary);
824 cmd.args(args);
825 cmd.stdin(Stdio::null());
826 cmd.env_remove("CLAUDECODE");
827 cmd.env_remove("CLAUDE_CODE_ENTRYPOINT");
828
829 if let Some(dir) = working_dir {
830 cmd.current_dir(dir);
831 }
832
833 for (key, value) in env {
834 cmd.env(key, value);
835 }
836
837 let output = cmd.output().map_err(|e| Error::Io {
838 message: format!("failed to spawn claude: {e}"),
839 source: e,
840 working_dir: working_dir.map(|p| p.to_path_buf()),
841 })?;
842
843 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
844 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
845 let exit_code = output.status.code().unwrap_or(-1);
846
847 if !output.status.success() {
848 return Err(Error::from_command_failure(
849 format!("{} {}", binary.display(), args.join(" ")),
850 exit_code,
851 stdout,
852 stderr,
853 working_dir.map(|p| p.to_path_buf()),
854 ));
855 }
856
857 Ok(CommandOutput {
858 stdout,
859 stderr,
860 exit_code,
861 success: true,
862 })
863}
864
865#[cfg(feature = "sync")]
872fn run_with_timeout_sync(
873 binary: &std::path::Path,
874 args: &[String],
875 env: &std::collections::HashMap<String, String>,
876 working_dir: Option<&std::path::Path>,
877 timeout: Duration,
878) -> Result<CommandOutput> {
879 use std::process::{Command as StdCommand, Stdio};
880 use std::thread;
881 use wait_timeout::ChildExt;
882
883 let mut cmd = StdCommand::new(binary);
884 cmd.args(args);
885 cmd.stdin(Stdio::null());
886 cmd.stdout(Stdio::piped());
887 cmd.stderr(Stdio::piped());
888 cmd.env_remove("CLAUDECODE");
889 cmd.env_remove("CLAUDE_CODE_ENTRYPOINT");
890
891 if let Some(dir) = working_dir {
892 cmd.current_dir(dir);
893 }
894
895 for (key, value) in env {
896 cmd.env(key, value);
897 }
898
899 let mut child = cmd.spawn().map_err(|e| Error::Io {
900 message: format!("failed to spawn claude: {e}"),
901 source: e,
902 working_dir: working_dir.map(|p| p.to_path_buf()),
903 })?;
904
905 let stdout = child.stdout.take().expect("stdout was piped");
910 let stderr = child.stderr.take().expect("stderr was piped");
911
912 let stdout_thread = thread::spawn(move || drain_sync(stdout));
913 let stderr_thread = thread::spawn(move || drain_sync(stderr));
914
915 match child.wait_timeout(timeout).map_err(|e| Error::Io {
916 message: "failed to wait for claude process".to_string(),
917 source: e,
918 working_dir: working_dir.map(|p| p.to_path_buf()),
919 })? {
920 Some(status) => {
921 let stdout = stdout_thread.join().unwrap_or_default();
922 let stderr = stderr_thread.join().unwrap_or_default();
923 let exit_code = status.code().unwrap_or(-1);
924
925 if !status.success() {
926 return Err(Error::from_command_failure(
927 format!("{} {}", binary.display(), args.join(" ")),
928 exit_code,
929 stdout,
930 stderr,
931 working_dir.map(|p| p.to_path_buf()),
932 ));
933 }
934
935 Ok(CommandOutput {
936 stdout,
937 stderr,
938 exit_code,
939 success: true,
940 })
941 }
942 None => {
943 let _ = child.kill();
948 let _ = child.wait();
949
950 let (stdout_str, stderr_str) =
951 join_with_deadline(stdout_thread, stderr_thread, Duration::from_millis(200));
952
953 if !stdout_str.is_empty() || !stderr_str.is_empty() {
954 warn!(
955 stdout = %stdout_str,
956 stderr = %stderr_str,
957 "partial output from timed-out process",
958 );
959 }
960
961 Err(Error::Timeout {
962 timeout_seconds: timeout.as_secs(),
963 })
964 }
965 }
966}
967
968#[cfg(feature = "sync")]
969fn drain_sync<R: std::io::Read>(mut reader: R) -> String {
970 let mut buf = Vec::new();
971 let _ = reader.read_to_end(&mut buf);
972 String::from_utf8_lossy(&buf).into_owned()
973}
974
975#[cfg(feature = "sync")]
981fn join_with_deadline(
982 stdout_thread: std::thread::JoinHandle<String>,
983 stderr_thread: std::thread::JoinHandle<String>,
984 budget: Duration,
985) -> (String, String) {
986 use std::sync::mpsc;
987 use std::thread;
988
989 let (tx, rx) = mpsc::channel::<(&'static str, String)>();
990
991 let tx_out = tx.clone();
992 let tx_err = tx;
993
994 thread::spawn(move || {
995 let s = stdout_thread.join().unwrap_or_default();
996 let _ = tx_out.send(("stdout", s));
997 });
998 thread::spawn(move || {
999 let s = stderr_thread.join().unwrap_or_default();
1000 let _ = tx_err.send(("stderr", s));
1001 });
1002
1003 let mut stdout = String::new();
1004 let mut stderr = String::new();
1005 let deadline = std::time::Instant::now() + budget;
1006
1007 for _ in 0..2 {
1008 let now = std::time::Instant::now();
1009 if now >= deadline {
1010 break;
1011 }
1012 match rx.recv_timeout(deadline - now) {
1013 Ok(("stdout", s)) => stdout = s,
1014 Ok(("stderr", s)) => stderr = s,
1015 Ok(_) => unreachable!(),
1016 Err(_) => break,
1017 }
1018 }
1019
1020 (stdout, stderr)
1021}
1022
1023#[cfg(all(test, unix, any(feature = "async", feature = "sync")))]
1030mod tests {
1031 use super::*;
1032 use std::io::Write;
1033 use std::os::unix::fs::PermissionsExt;
1034
1035 use crate::Claude;
1036
1037 fn fake_script(body: &str) -> (tempfile::TempDir, std::path::PathBuf) {
1041 let dir = tempfile::tempdir().expect("tempdir");
1042 let path = dir.path().join("fake-claude.sh");
1043 let mut f = std::fs::File::create(&path).expect("create script");
1044 write!(f, "#!/usr/bin/env bash\n{body}\n").expect("write script");
1045 let perms = std::fs::Permissions::from_mode(0o755);
1046 std::fs::set_permissions(&path, perms).expect("chmod");
1047 (dir, path)
1048 }
1049
1050 fn client(path: &std::path::Path) -> Claude {
1051 Claude::builder()
1052 .binary(path)
1053 .build()
1054 .expect("build client")
1055 }
1056
1057 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1059
1060 fn set_scrub_vars() {
1061 let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1062 unsafe {
1065 std::env::set_var("CLAUDECODE", "1");
1066 std::env::set_var("CLAUDE_CODE_ENTRYPOINT", "cli");
1067 }
1068 }
1069
1070 fn clear_scrub_vars() {
1071 let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1072 unsafe {
1074 std::env::remove_var("CLAUDECODE");
1075 std::env::remove_var("CLAUDE_CODE_ENTRYPOINT");
1076 }
1077 }
1078
1079 #[cfg(feature = "async")]
1082 #[tokio::test]
1083 async fn async_success_maps_output() {
1084 let (_dir, path) = fake_script(r#"echo "hi there"; exit 0"#);
1085 let out = run_claude(&client(&path), vec!["--version".into()])
1086 .await
1087 .expect("success");
1088 assert!(out.success);
1089 assert_eq!(out.exit_code, 0);
1090 assert!(out.stdout.contains("hi there"));
1091 }
1092
1093 #[cfg(feature = "async")]
1094 #[tokio::test]
1095 async fn async_nonzero_exit_maps_command_failed() {
1096 let (_dir, path) = fake_script(r#"echo "boom" >&2; exit 3"#);
1097 let err = run_claude(&client(&path), vec![]).await.unwrap_err();
1098 match err {
1099 Error::CommandFailed {
1100 exit_code, stderr, ..
1101 } => {
1102 assert_eq!(exit_code, 3);
1103 assert!(stderr.contains("boom"));
1104 }
1105 other => panic!("expected CommandFailed, got {other:?}"),
1106 }
1107 }
1108
1109 #[cfg(feature = "async")]
1110 #[tokio::test]
1111 async fn async_rail_stop_maps_max_turns() {
1112 let (_dir, path) = fake_script(
1113 r#"echo '{"type":"result","subtype":"error_max_turns","is_error":true,"errors":["Reached maximum number of turns (2)"]}'; exit 1"#,
1114 );
1115 let err = run_claude(&client(&path), vec![]).await.unwrap_err();
1116 assert!(
1117 matches!(
1118 err,
1119 Error::MaxTurnsExceeded {
1120 max_turns: Some(2),
1121 ..
1122 }
1123 ),
1124 "got: {err:?}"
1125 );
1126 }
1127
1128 #[cfg(feature = "async")]
1129 #[tokio::test]
1130 async fn async_auth_shaped_stderr_maps_auth() {
1131 let (_dir, path) =
1132 fake_script(r#"echo "Not authenticated. Run `claude login`." >&2; exit 1"#);
1133 let err = run_claude(&client(&path), vec![]).await.unwrap_err();
1134 assert!(matches!(err, Error::Auth { .. }), "got: {err:?}");
1135 }
1136
1137 #[cfg(feature = "async")]
1138 #[tokio::test]
1139 async fn async_scrubs_claude_env_vars() {
1140 let (_dir, path) =
1141 fake_script(r#"echo "CC=[${CLAUDECODE:-}] EP=[${CLAUDE_CODE_ENTRYPOINT:-}]""#);
1142 set_scrub_vars();
1148 let out = run_claude(&client(&path), vec![]).await.expect("success");
1149 clear_scrub_vars();
1150 assert!(out.stdout.contains("CC=[]"), "got: {}", out.stdout);
1151 assert!(out.stdout.contains("EP=[]"), "got: {}", out.stdout);
1152 }
1153
1154 #[cfg(feature = "async")]
1155 #[tokio::test]
1156 async fn async_applies_working_dir() {
1157 let (_dir, path) = fake_script(r#"pwd"#);
1158 let workdir = tempfile::tempdir().expect("workdir");
1159 let claude = Claude::builder()
1160 .binary(&path)
1161 .working_dir(workdir.path())
1162 .build()
1163 .expect("build");
1164 let out = run_claude(&claude, vec![]).await.expect("success");
1165 let got = std::fs::canonicalize(out.stdout.trim()).expect("canonicalize pwd");
1166 let want = std::fs::canonicalize(workdir.path()).expect("canonicalize workdir");
1167 assert_eq!(got, want);
1168 }
1169
1170 #[cfg(feature = "async")]
1171 #[tokio::test]
1172 async fn async_stdin_prompt_round_trips() {
1173 let (_dir, path) = fake_script(r#"cat"#);
1174 let out = run_claude_with_stdin_prompt(&client(&path), vec![], "hello via stdin".into())
1175 .await
1176 .expect("success");
1177 assert!(out.stdout.contains("hello via stdin"));
1178 }
1179
1180 #[cfg(feature = "async")]
1181 #[tokio::test]
1182 async fn async_allow_exit_codes_permits_listed_code() {
1183 let (_dir, path) = fake_script(r#"echo out; exit 2"#);
1184 let out = run_claude_allow_exit_codes(&client(&path), vec![], &[2])
1185 .await
1186 .expect("allowed code is Ok");
1187 assert!(!out.success);
1188 assert_eq!(out.exit_code, 2);
1189 assert!(out.stdout.contains("out"));
1190 }
1191
1192 #[cfg(feature = "async")]
1193 #[tokio::test]
1194 async fn async_allow_exit_codes_still_errors_on_unlisted_code() {
1195 let (_dir, path) = fake_script(r#"exit 2"#);
1196 let err = run_claude_allow_exit_codes(&client(&path), vec![], &[5])
1197 .await
1198 .unwrap_err();
1199 assert!(
1200 matches!(err, Error::CommandFailed { exit_code: 2, .. }),
1201 "got: {err:?}"
1202 );
1203 }
1204
1205 #[cfg(feature = "async")]
1206 #[tokio::test]
1207 async fn async_timeout_fires_on_slow_child() {
1208 let (_dir, path) = fake_script(r#"sleep 3; echo done"#);
1209 let claude = Claude::builder()
1210 .binary(&path)
1211 .timeout(Duration::from_millis(300))
1212 .build()
1213 .expect("build");
1214 let err = run_claude(&claude, vec![]).await.unwrap_err();
1215 assert!(matches!(err, Error::Timeout { .. }), "got: {err:?}");
1216 }
1217
1218 #[cfg(feature = "async")]
1219 #[tokio::test]
1220 async fn async_timeout_path_returns_output_when_fast() {
1221 let (_dir, path) = fake_script(r#"echo quick"#);
1222 let claude = Claude::builder()
1223 .binary(&path)
1224 .timeout(Duration::from_secs(30))
1225 .build()
1226 .expect("build");
1227 let out = run_claude(&claude, vec![]).await.expect("success");
1228 assert!(out.stdout.contains("quick"));
1229 }
1230
1231 #[cfg(feature = "async")]
1232 #[tokio::test]
1233 async fn async_timeout_path_maps_command_failed() {
1234 let (_dir, path) = fake_script(r#"echo e >&2; exit 4"#);
1235 let claude = Claude::builder()
1236 .binary(&path)
1237 .timeout(Duration::from_secs(30))
1238 .build()
1239 .expect("build");
1240 let err = run_claude(&claude, vec![]).await.unwrap_err();
1241 assert!(
1242 matches!(err, Error::CommandFailed { exit_code: 4, .. }),
1243 "got: {err:?}"
1244 );
1245 }
1246
1247 #[cfg(feature = "async")]
1248 #[tokio::test]
1249 async fn async_stdin_with_timeout_round_trips() {
1250 let (_dir, path) = fake_script(r#"cat"#);
1251 let claude = Claude::builder()
1252 .binary(&path)
1253 .timeout(Duration::from_secs(30))
1254 .build()
1255 .expect("build");
1256 let out = run_claude_with_stdin_prompt(&claude, vec![], "piped under timeout".into())
1257 .await
1258 .expect("success");
1259 assert!(out.stdout.contains("piped under timeout"));
1260 }
1261
1262 #[cfg(feature = "async")]
1263 #[tokio::test]
1264 async fn async_stdin_timeout_fires_on_slow_child() {
1265 let (_dir, path) = fake_script(r#"sleep 3"#);
1266 let claude = Claude::builder()
1267 .binary(&path)
1268 .timeout(Duration::from_millis(300))
1269 .build()
1270 .expect("build");
1271 let err = run_claude_with_stdin_prompt(&claude, vec![], "x".into())
1272 .await
1273 .unwrap_err();
1274 assert!(matches!(err, Error::Timeout { .. }), "got: {err:?}");
1275 }
1276
1277 #[cfg(feature = "async")]
1278 #[tokio::test]
1279 async fn async_spawn_failure_maps_io() {
1280 let claude = Claude::builder()
1281 .binary("/nonexistent/definitely/not/here")
1282 .build()
1283 .expect("build");
1284 let err = run_claude(&claude, vec![]).await.unwrap_err();
1285 assert!(matches!(err, Error::Io { .. }), "got: {err:?}");
1286 }
1287
1288 #[cfg(feature = "sync")]
1291 #[test]
1292 fn sync_success_maps_output() {
1293 let (_dir, path) = fake_script(r#"echo "hi sync"; exit 0"#);
1294 let out = run_claude_sync(&client(&path), vec![]).expect("success");
1295 assert!(out.success);
1296 assert!(out.stdout.contains("hi sync"));
1297 }
1298
1299 #[cfg(feature = "sync")]
1300 #[test]
1301 fn sync_nonzero_exit_maps_command_failed() {
1302 let (_dir, path) = fake_script(r#"echo "boom" >&2; exit 3"#);
1303 let err = run_claude_sync(&client(&path), vec![]).unwrap_err();
1304 match err {
1305 Error::CommandFailed {
1306 exit_code, stderr, ..
1307 } => {
1308 assert_eq!(exit_code, 3);
1309 assert!(stderr.contains("boom"));
1310 }
1311 other => panic!("expected CommandFailed, got {other:?}"),
1312 }
1313 }
1314
1315 #[cfg(feature = "sync")]
1316 #[test]
1317 fn sync_scrubs_claude_env_vars() {
1318 let (_dir, path) =
1319 fake_script(r#"echo "CC=[${CLAUDECODE:-}] EP=[${CLAUDE_CODE_ENTRYPOINT:-}]""#);
1320 set_scrub_vars();
1321 let out = run_claude_sync(&client(&path), vec![]).expect("success");
1322 clear_scrub_vars();
1323 assert!(out.stdout.contains("CC=[]"), "got: {}", out.stdout);
1324 assert!(out.stdout.contains("EP=[]"), "got: {}", out.stdout);
1325 }
1326
1327 #[cfg(feature = "sync")]
1328 #[test]
1329 fn sync_stdin_prompt_round_trips() {
1330 let (_dir, path) = fake_script(r#"cat"#);
1331 let out = run_claude_with_stdin_prompt_sync(&client(&path), vec![], "sync stdin".into())
1332 .expect("success");
1333 assert!(out.stdout.contains("sync stdin"));
1334 }
1335
1336 #[cfg(feature = "sync")]
1337 #[test]
1338 fn sync_allow_exit_codes_permits_listed_code() {
1339 let (_dir, path) = fake_script(r#"echo out; exit 2"#);
1340 let out = run_claude_allow_exit_codes_sync(&client(&path), vec![], &[2])
1341 .expect("allowed code is Ok");
1342 assert!(!out.success);
1343 assert_eq!(out.exit_code, 2);
1344 }
1345
1346 #[cfg(feature = "sync")]
1347 #[test]
1348 fn sync_timeout_fires_on_slow_child() {
1349 let (_dir, path) = fake_script(r#"sleep 3; echo done"#);
1350 let claude = Claude::builder()
1351 .binary(&path)
1352 .timeout(Duration::from_millis(300))
1353 .build()
1354 .expect("build");
1355 let err = run_claude_sync(&claude, vec![]).unwrap_err();
1356 assert!(matches!(err, Error::Timeout { .. }), "got: {err:?}");
1357 }
1358
1359 #[cfg(feature = "sync")]
1360 #[test]
1361 fn sync_timeout_path_returns_output_when_fast() {
1362 let (_dir, path) = fake_script(r#"echo quick"#);
1363 let claude = Claude::builder()
1364 .binary(&path)
1365 .timeout(Duration::from_secs(30))
1366 .build()
1367 .expect("build");
1368 let out = run_claude_sync(&claude, vec![]).expect("success");
1369 assert!(out.stdout.contains("quick"));
1370 }
1371
1372 #[cfg(feature = "sync")]
1373 #[test]
1374 fn sync_stdin_with_timeout_round_trips() {
1375 let (_dir, path) = fake_script(r#"cat"#);
1376 let claude = Claude::builder()
1377 .binary(&path)
1378 .timeout(Duration::from_secs(30))
1379 .build()
1380 .expect("build");
1381 let out = run_claude_with_stdin_prompt_sync(&claude, vec![], "sync piped".into())
1382 .expect("success");
1383 assert!(out.stdout.contains("sync piped"));
1384 }
1385}