Skip to main content

oven_cli/process/
mod.rs

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/// Result from a Claude agent invocation.
11#[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/// Result from a simple command execution (e.g., gh CLI).
22#[derive(Debug, Clone)]
23pub struct CommandOutput {
24    pub stdout: String,
25    pub stderr: String,
26    pub success: bool,
27}
28
29/// Trait for running external commands.
30///
31/// Enables mocking in tests so we never call real CLIs.
32/// Uses `String` slices rather than `&str` slices for mockall compatibility.
33#[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
50/// Real implementation that spawns actual subprocesses.
51pub 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        // Pass prompt via stdin to avoid leaking it in process listings (ps aux).
81        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}