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    }
82
83    #[tokio::test]
84    async fn shell_run_echo() {
85        let client = ShellClient::new();
86        let result = client.run("echo hello".to_string()).await.unwrap();
87        assert_eq!(result.exit_code, 0);
88        assert_eq!(result.stdout.trim(), "hello");
89        assert!(result.stderr.is_empty());
90    }
91
92    #[tokio::test]
93    async fn shell_run_exit_code() {
94        let client = ShellClient::new();
95        let result = client.run("exit 42".to_string()).await.unwrap();
96        assert_eq!(result.exit_code, 42);
97    }
98
99    #[tokio::test]
100    async fn shell_run_stderr() {
101        let client = ShellClient::new();
102        let result = client.run("echo error >&2".to_string()).await.unwrap();
103        assert_eq!(result.exit_code, 0);
104        assert!(result.stdout.is_empty());
105        assert_eq!(result.stderr.trim(), "error");
106    }
107
108    #[tokio::test]
109    async fn shell_run_complex_command() {
110        let client = ShellClient::new();
111        let result = client
112            .run("echo 'line1'; echo 'line2'".to_string())
113            .await
114            .unwrap();
115        assert_eq!(result.exit_code, 0);
116        assert!(result.stdout.contains("line1"));
117        assert!(result.stdout.contains("line2"));
118    }
119}