Skip to main content

sage_runtime/tools/
shell.rs

1//! RFC-0011: Shell tool for Sage agents.
2//!
3//! Provides the `Shell` tool with command execution capabilities.
4
5use crate::error::{SageError, SageResult};
6use crate::mock::{try_get_mock, MockResponse};
7
8/// Result of running a shell command.
9#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
10pub struct ShellResult {
11    /// Exit code from the command.
12    pub exit_code: i64,
13    /// Standard output from the command.
14    pub stdout: String,
15    /// Standard error from the command.
16    pub stderr: String,
17}
18
19/// Shell client for Sage agents.
20///
21/// Provides command execution via the system shell.
22#[derive(Debug, Clone, Default)]
23pub struct ShellClient;
24
25impl ShellClient {
26    /// Create a new shell client.
27    pub fn new() -> Self {
28        Self
29    }
30
31    /// Create a new shell client from environment variables.
32    ///
33    /// Currently no environment configuration is needed.
34    pub fn from_env() -> Self {
35        Self
36    }
37
38    /// Run a shell command.
39    ///
40    /// # Arguments
41    /// * `command` - The command to run (passed to `sh -c`)
42    ///
43    /// # Returns
44    /// A `ShellResult` with exit code, stdout, and stderr.
45    pub async fn run(&self, command: String) -> SageResult<ShellResult> {
46        // Check for mock response first
47        if let Some(mock_response) = try_get_mock("Shell", "run") {
48            return Self::apply_mock(mock_response);
49        }
50
51        let output = tokio::process::Command::new("sh")
52            .arg("-c")
53            .arg(&command)
54            .output()
55            .await?;
56
57        Ok(ShellResult {
58            exit_code: output.status.code().unwrap_or(-1) as i64,
59            stdout: String::from_utf8_lossy(&output.stdout).to_string(),
60            stderr: String::from_utf8_lossy(&output.stderr).to_string(),
61        })
62    }
63
64    /// Apply a mock response, deserializing it to ShellResult.
65    fn apply_mock(mock_response: MockResponse) -> SageResult<ShellResult> {
66        match mock_response {
67            MockResponse::Value(v) => serde_json::from_value(v)
68                .map_err(|e| SageError::Tool(format!("mock deserialize: {e}"))),
69            MockResponse::Fail(msg) => Err(SageError::Tool(msg)),
70        }
71    }
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77
78    #[test]
79    fn shell_client_creates() {
80        let client = ShellClient::new();
81        drop(client);
82    }
83
84    #[tokio::test]
85    async fn shell_run_echo() {
86        let client = ShellClient::new();
87        let result = client.run("echo hello".to_string()).await.unwrap();
88        assert_eq!(result.exit_code, 0);
89        assert_eq!(result.stdout.trim(), "hello");
90        assert!(result.stderr.is_empty());
91    }
92
93    #[tokio::test]
94    async fn shell_run_exit_code() {
95        let client = ShellClient::new();
96        let result = client.run("exit 42".to_string()).await.unwrap();
97        assert_eq!(result.exit_code, 42);
98    }
99
100    #[tokio::test]
101    async fn shell_run_stderr() {
102        let client = ShellClient::new();
103        let result = client
104            .run("echo error >&2".to_string())
105            .await
106            .unwrap();
107        assert_eq!(result.exit_code, 0);
108        assert!(result.stdout.is_empty());
109        assert_eq!(result.stderr.trim(), "error");
110    }
111
112    #[tokio::test]
113    async fn shell_run_complex_command() {
114        let client = ShellClient::new();
115        let result = client
116            .run("echo 'line1'; echo 'line2'".to_string())
117            .await
118            .unwrap();
119        assert_eq!(result.exit_code, 0);
120        assert!(result.stdout.contains("line1"));
121        assert!(result.stdout.contains("line2"));
122    }
123}