Skip to main content

a3s_code_core/skills/
preprocessor.rs

1//! Shell command preprocessor for skill prompts
2//!
3//! Supports two syntaxes in skill markdown content:
4//! - Code blocks: ```! command ```
5//! - Inline: !`command`
6//!
7//! ## Usage
8//!
9//! ```rust
10//! use a3s_code_core::skills::ShellPreprocessor;
11//!
12//! let preprocessor = ShellPreprocessor::new();
13//! let result = preprocessor.process("Run !`echo hello`", &context).await?;
14//! ```
15
16use crate::error::Result;
17use crate::tools::{ToolExecutor, ToolResult};
18use async_trait::async_trait;
19use std::path::Path;
20use std::sync::Arc;
21
22/// Shell command output extractor
23#[async_trait]
24pub trait ShellPreprocessor: Send + Sync {
25    /// Process skill content, executing any embedded shell commands
26    async fn process(
27        &self,
28        content: &str,
29        workspace: &Path,
30        executor: &Arc<ToolExecutor>,
31    ) -> Result<String>;
32}
33
34/// Default shell preprocessor implementation
35pub struct DefaultShellPreprocessor {
36    /// Maximum shell output length to substitute (bytes)
37    max_output_length: usize,
38}
39
40impl DefaultShellPreprocessor {
41    pub fn new() -> Self {
42        Self {
43            max_output_length: 10 * 1024, // 10KB
44        }
45    }
46
47    /// Process inline shell commands: !`command`
48    fn process_inline(&self, content: &str) -> Vec<(String, String)> {
49        let mut results = Vec::new();
50        // Pattern: !`command` - must be at start of line or after whitespace
51        // Cannot use lookbehind, so match the preceding char too and strip it
52        let re = regex::Regex::new(r"(?:^|\s)(!`([^`]+)`)").unwrap();
53
54        for cap in re.captures_iter(content) {
55            let full_match = cap.get(1).unwrap().as_str();
56            let command = cap.get(2).unwrap().as_str().trim();
57            results.push((full_match.to_string(), command.to_string()));
58        }
59
60        results
61    }
62
63    /// Process code block shell commands: ```! command ```
64    fn process_blocks(&self, content: &str) -> Vec<(String, String)> {
65        let mut results = Vec::new();
66        // Pattern: ```! command ```
67        let re = regex::Regex::new(r"```!\s*\n?([\s\S]*?)\n?```").unwrap();
68
69        for cap in re.captures_iter(content) {
70            let full_match = cap.get(0).unwrap().as_str();
71            let command = cap.get(1).unwrap().as_str().trim();
72            if !command.is_empty() {
73                results.push((full_match.to_string(), command.to_string()));
74            }
75        }
76
77        results
78    }
79}
80
81impl Default for DefaultShellPreprocessor {
82    fn default() -> Self {
83        Self::new()
84    }
85}
86
87#[async_trait]
88impl ShellPreprocessor for DefaultShellPreprocessor {
89    async fn process(
90        &self,
91        content: &str,
92        workspace: &Path,
93        executor: &Arc<ToolExecutor>,
94    ) -> Result<String> {
95        let mut result = content.to_string();
96
97        // Process code blocks first: ```! command ```
98        for (pattern, command) in self.process_blocks(content) {
99            let output = self.execute_command(&command, workspace, executor).await?;
100            result = result.replace(&pattern, &output);
101        }
102
103        // Process inline commands: !`command`
104        // Gate on presence of !` to avoid expensive regex on large content
105        if result.contains("!`") {
106            for (pattern, command) in self.process_inline(&result) {
107                let output = self.execute_command(&command, workspace, executor).await?;
108                result = result.replace(&pattern, &output);
109            }
110        }
111
112        Ok(result)
113    }
114}
115
116impl DefaultShellPreprocessor {
117    async fn execute_command(
118        &self,
119        command: &str,
120        workspace: &Path,
121        executor: &Arc<ToolExecutor>,
122    ) -> Result<String> {
123        let args = serde_json::json!({ "command": command });
124        let result: ToolResult = executor
125            .execute_with_context(
126                "bash",
127                &args,
128                &crate::tools::ToolContext {
129                    workspace: workspace.to_path_buf(),
130                    session_id: None,
131                    event_tx: None,
132                    agent_event_tx: None,
133                    search_config: None,
134                    agentic_search_config: None,
135                    agentic_parse_config: None,
136                    document_parser_config: None,
137                    sandbox: None,
138                    command_env: None,
139                    document_parsers: None,
140                    document_pipeline: None,
141                },
142            )
143            .await?;
144
145        if result.exit_code != 0 {
146            return Ok(format!("[Error: {}]", result.output));
147        }
148
149        let output = result.output.trim().to_string();
150        if output.len() > self.max_output_length {
151            Ok(format!(
152                "{}... [truncated {} bytes]",
153                &output[..self.max_output_length],
154                output.len() - self.max_output_length
155            ))
156        } else {
157            Ok(output)
158        }
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    #[test]
167    fn test_process_inline_pattern() {
168        let preprocessor = DefaultShellPreprocessor::new();
169        let matches = preprocessor.process_inline("Run !`echo hello` now");
170        assert_eq!(matches.len(), 1);
171        assert_eq!(matches[0].0, "!`echo hello`");
172        assert_eq!(matches[0].1, "echo hello");
173    }
174
175    #[test]
176    fn test_process_block_pattern() {
177        let preprocessor = DefaultShellPreprocessor::new();
178        let content = r#"```!
179ls -la
180```"#;
181        let matches = preprocessor.process_blocks(content);
182        assert_eq!(matches.len(), 1);
183        assert_eq!(matches[0].1, "ls -la");
184    }
185
186    #[test]
187    fn test_process_block_pattern_with_newlines() {
188        let preprocessor = DefaultShellPreprocessor::new();
189        // Block pattern requires ```! (not just ``` followed by !)
190        let content = r#"```!
191ls -la
192```"#;
193        let matches = preprocessor.process_blocks(content);
194        assert_eq!(matches.len(), 1);
195        assert_eq!(matches[0].1, "ls -la");
196    }
197
198    #[test]
199    fn test_no_false_positives() {
200        let preprocessor = DefaultShellPreprocessor::new();
201        // $! (shell variable ending in !) should not match since it's not !`
202        let matches = preprocessor.process_inline("echo $! and !`echo hi`");
203        assert_eq!(matches.len(), 1); // only the actual !`...` should match
204        assert_eq!(matches[0].1, "echo hi");
205    }
206
207    #[test]
208    fn test_process_inline_at_start_of_line() {
209        let preprocessor = DefaultShellPreprocessor::new();
210        let matches = preprocessor.process_inline("!`pwd`");
211        assert_eq!(matches.len(), 1);
212        assert_eq!(matches[0].1, "pwd");
213    }
214
215    #[test]
216    fn test_process_inline_multiple() {
217        let preprocessor = DefaultShellPreprocessor::new();
218        let matches = preprocessor.process_inline("First !`cmd1` then !`cmd2`");
219        assert_eq!(matches.len(), 2);
220        assert_eq!(matches[0].1, "cmd1");
221        assert_eq!(matches[1].1, "cmd2");
222    }
223
224    #[test]
225    fn test_process_inline_no_matches() {
226        let preprocessor = DefaultShellPreprocessor::new();
227        let matches = preprocessor.process_inline("No shell commands here");
228        assert!(matches.is_empty());
229    }
230
231    #[test]
232    fn test_process_inline_backtick_in_string() {
233        let preprocessor = DefaultShellPreprocessor::new();
234        // Should not match backticks without !
235        let matches = preprocessor.process_inline("`echo hello`");
236        assert!(matches.is_empty());
237    }
238
239    #[test]
240    fn test_process_blocks_multiple() {
241        let preprocessor = DefaultShellPreprocessor::new();
242        let content = r#"```!
243ls -la
244```
245Some text
246```!
247pwd
248```"#;
249        let matches = preprocessor.process_blocks(content);
250        assert_eq!(matches.len(), 2);
251        assert_eq!(matches[0].1, "ls -la");
252        assert_eq!(matches[1].1, "pwd");
253    }
254
255    #[test]
256    fn test_process_blocks_no_matches() {
257        let preprocessor = DefaultShellPreprocessor::new();
258        let content = "```\nsome code\n```";
259        let matches = preprocessor.process_blocks(content);
260        assert!(matches.is_empty());
261    }
262
263    #[test]
264    fn test_process_blocks_empty_command() {
265        let preprocessor = DefaultShellPreprocessor::new();
266        let content = "```!\n```";
267        let matches = preprocessor.process_blocks(content);
268        // Empty commands are filtered out
269        assert!(matches.is_empty());
270    }
271
272    #[test]
273    fn test_default_shell_preprocessor_new() {
274        let preprocessor = DefaultShellPreprocessor::new();
275        assert_eq!(preprocessor.max_output_length, 10 * 1024);
276    }
277}