1pub mod stream;
2
3use std::{path::Path, time::Duration};
4
5use anyhow::{Context, Result};
6use tokio::{io::AsyncWriteExt, process::Command};
7
8use self::stream::parse_stream;
9
10#[derive(Debug, Clone)]
12pub struct AgentResult {
13 pub cost_usd: f64,
14 pub duration: Duration,
15 pub turns: u32,
16 pub output: String,
17 pub session_id: String,
18 pub success: bool,
19}
20
21#[derive(Debug, Clone)]
23pub struct CommandOutput {
24 pub stdout: String,
25 pub stderr: String,
26 pub success: bool,
27}
28
29#[cfg_attr(test, mockall::automock)]
34pub trait CommandRunner: Send + Sync {
35 fn run_claude(
36 &self,
37 prompt: &str,
38 allowed_tools: &[String],
39 working_dir: &Path,
40 max_turns: Option<u32>,
41 ) -> impl std::future::Future<Output = Result<AgentResult>> + Send;
42
43 fn run_gh(
44 &self,
45 args: &[String],
46 working_dir: &Path,
47 ) -> impl std::future::Future<Output = Result<CommandOutput>> + Send;
48}
49
50pub struct RealCommandRunner;
52
53impl CommandRunner for RealCommandRunner {
54 async fn run_claude(
55 &self,
56 prompt: &str,
57 allowed_tools: &[String],
58 working_dir: &Path,
59 max_turns: Option<u32>,
60 ) -> Result<AgentResult> {
61 let tools_arg = allowed_tools.join(",");
62
63 let mut cmd = Command::new("claude");
64 cmd.args(["-p", "--verbose", "--output-format", "stream-json"])
65 .args(["--allowedTools", &tools_arg]);
66
67 if let Some(turns) = max_turns {
68 cmd.args(["--max-turns", &turns.to_string()]);
69 }
70
71 let mut child = cmd
72 .current_dir(working_dir)
73 .stdin(std::process::Stdio::piped())
74 .stdout(std::process::Stdio::piped())
75 .stderr(std::process::Stdio::piped())
76 .kill_on_drop(true)
77 .spawn()
78 .context("spawning claude")?;
79
80 let mut stdin = child.stdin.take().context("capturing claude stdin")?;
82 stdin.write_all(prompt.as_bytes()).await.context("writing prompt to claude stdin")?;
83 stdin.shutdown().await.context("closing claude stdin")?;
84 drop(stdin);
85
86 let stdout = child.stdout.take().context("capturing claude stdout")?;
87 let result = parse_stream(stdout).await?;
88 let status = child.wait().await.context("waiting for claude")?;
89
90 Ok(AgentResult {
91 cost_usd: result.cost_usd,
92 duration: result.duration,
93 turns: result.turns,
94 output: result.output,
95 session_id: result.session_id,
96 success: status.success(),
97 })
98 }
99
100 async fn run_gh(&self, args: &[String], working_dir: &Path) -> Result<CommandOutput> {
101 let output = Command::new("gh")
102 .args(args)
103 .current_dir(working_dir)
104 .kill_on_drop(true)
105 .output()
106 .await
107 .context("spawning gh")?;
108
109 Ok(CommandOutput {
110 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
111 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
112 success: output.status.success(),
113 })
114 }
115}
116
117#[cfg(test)]
118mod tests {
119 use super::*;
120
121 #[test]
122 fn agent_result_is_send_sync() {
123 fn assert_send_sync<T: Send + Sync>() {}
124 assert_send_sync::<AgentResult>();
125 assert_send_sync::<CommandOutput>();
126 }
127
128 #[test]
129 fn real_command_runner_is_send_sync() {
130 fn assert_send_sync<T: Send + Sync>() {}
131 assert_send_sync::<RealCommandRunner>();
132 }
133}