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