Skip to main content

recursive/tools/
run_skill_script.rs

1//! Tool to run a script from a skill's `scripts/` directory.
2//!
3//! Finds the named script within a named skill, executes it with optional
4//! arguments, and returns the combined stdout+stderr output.
5
6use async_trait::async_trait;
7use serde_json::{json, Value};
8use std::path::PathBuf;
9use std::process::Stdio;
10use std::sync::Arc;
11use std::time::Duration;
12use tokio::io::AsyncReadExt;
13use tokio::process::Command;
14
15use crate::error::{Error, Result};
16use crate::llm::ToolSpec;
17use crate::skills::Skill;
18use crate::tools::Tool;
19
20/// Tool to run a script from a skill's scripts/ directory.
21pub struct RunSkillScript {
22    skills: Arc<Vec<Skill>>,
23    workspace: PathBuf,
24    timeout: Duration,
25}
26
27impl RunSkillScript {
28    pub fn new(skills: Vec<Skill>, workspace: PathBuf, timeout: Duration) -> Self {
29        Self {
30            skills: Arc::new(skills),
31            workspace,
32            timeout,
33        }
34    }
35}
36
37#[async_trait]
38impl Tool for RunSkillScript {
39    fn spec(&self) -> ToolSpec {
40        ToolSpec {
41            name: "run_skill_script".into(),
42            description: "Run a script from a skill's scripts/ directory by skill and script name. The script executes in the workspace root.".into(),
43            parameters: json!({
44                "type": "object",
45                "properties": {
46                    "skill": {
47                        "type": "string",
48                        "description": "Name of the skill (case-insensitive)"
49                    },
50                    "script": {
51                        "type": "string",
52                        "description": "Name of the script within the skill (case-insensitive)"
53                    },
54                    "args": {
55                        "type": "string",
56                        "description": "Optional arguments to pass to the script"
57                    }
58                },
59                "required": ["skill", "script"]
60            }),
61        }
62    }
63
64    async fn execute(&self, arguments: Value) -> Result<String> {
65        let skill_name = arguments["skill"]
66            .as_str()
67            .ok_or_else(|| Error::BadToolArgs {
68                name: "run_skill_script".into(),
69                message: "missing required parameter: skill".to_string(),
70            })?;
71
72        let script_name = arguments["script"]
73            .as_str()
74            .ok_or_else(|| Error::BadToolArgs {
75                name: "run_skill_script".into(),
76                message: "missing required parameter: script".to_string(),
77            })?;
78
79        // Find the skill (case-insensitive)
80        let skill = self
81            .skills
82            .iter()
83            .find(|s| s.name.to_lowercase() == skill_name.to_lowercase())
84            .ok_or_else(|| Error::Tool {
85                name: "run_skill_script".into(),
86                message: format!("skill not found: {skill_name}"),
87            })?;
88
89        // Find the script within the skill (case-insensitive)
90        let script = skill
91            .scripts
92            .iter()
93            .find(|s| s.name.to_lowercase() == script_name.to_lowercase())
94            .ok_or_else(|| {
95                let available: Vec<&str> = skill.scripts.iter().map(|s| s.name.as_str()).collect();
96                let available_list = if available.is_empty() {
97                    "no scripts available for this skill".to_string()
98                } else {
99                    format!("available scripts: {}", available.join(", "))
100                };
101                Error::Tool {
102                    name: "run_skill_script".into(),
103                    message: format!("script not found: '{script_name}'. {available_list}"),
104                }
105            })?;
106
107        // Security: script path must be within the skill directory
108        let skill_dir = skill.path.parent().ok_or_else(|| Error::Tool {
109            name: "run_skill_script".into(),
110            message: "cannot determine skill directory".to_string(),
111        })?;
112
113        let canonical_script = std::fs::canonicalize(&script.path).map_err(|e| Error::Tool {
114            name: "run_skill_script".into(),
115            message: format!("cannot resolve script path: {e}"),
116        })?;
117
118        let canonical_skill_dir = std::fs::canonicalize(skill_dir).map_err(|e| Error::Tool {
119            name: "run_skill_script".into(),
120            message: format!("cannot resolve skill directory: {e}"),
121        })?;
122
123        if !canonical_script.starts_with(&canonical_skill_dir) {
124            return Err(Error::Tool {
125                name: "run_skill_script".into(),
126                message: "script path escapes skill directory".to_string(),
127            });
128        }
129
130        // Build the command
131        let args_str = arguments["args"].as_str().unwrap_or("");
132        let shell_command = if args_str.is_empty() {
133            script.path.to_string_lossy().to_string()
134        } else {
135            format!("{} {}", script.path.display(), args_str)
136        };
137
138        let mut cmd = Command::new("/bin/sh");
139        cmd.arg("-c")
140            .arg(&shell_command)
141            .current_dir(&self.workspace)
142            .stdout(Stdio::piped())
143            .stderr(Stdio::piped());
144
145        let mut child = cmd.spawn().map_err(|e| Error::Tool {
146            name: "run_skill_script".into(),
147            message: format!("spawn failed: {e}"),
148        })?;
149
150        let mut stdout = child.stdout.take().expect("stdout piped");
151        let mut stderr = child.stderr.take().expect("stderr piped");
152
153        let max_output: usize = 10000;
154        let stdout_task = tokio::spawn(async move { read_capped(&mut stdout, max_output).await });
155        let stderr_task = tokio::spawn(async move { read_capped(&mut stderr, max_output).await });
156
157        let wait = child.wait();
158        let status = match tokio::time::timeout(self.timeout, wait).await {
159            Ok(s) => s.map_err(|e| Error::Tool {
160                name: "run_skill_script".into(),
161                message: format!("wait failed: {e}"),
162            })?,
163            Err(_) => {
164                return Err(Error::Tool {
165                    name: "run_skill_script".into(),
166                    message: format!("script timed out after {:?}", self.timeout),
167                });
168            }
169        };
170
171        let out = stdout_task.await.unwrap_or_default();
172        let err = stderr_task.await.unwrap_or_default();
173        let code = status
174            .code()
175            .map(|c| c.to_string())
176            .unwrap_or_else(|| "signal".into());
177
178        Ok(format!(
179            "exit: {code}\n--- stdout ---\n{out}\n--- stderr ---\n{err}"
180        ))
181    }
182}
183
184async fn read_capped<R: AsyncReadExt + Unpin>(reader: &mut R, max: usize) -> String {
185    let mut buf = Vec::with_capacity(8 * 1024);
186    let mut tmp = [0u8; 8 * 1024];
187    loop {
188        match reader.read(&mut tmp).await {
189            Ok(0) => break,
190            Ok(n) => {
191                if buf.len() + n > max {
192                    let take = max.saturating_sub(buf.len());
193                    buf.extend_from_slice(&tmp[..take]);
194                    buf.extend_from_slice(b"\n... [output truncated]");
195                    let _ = tokio::io::copy(reader, &mut tokio::io::sink()).await;
196                    break;
197                }
198                buf.extend_from_slice(&tmp[..n]);
199            }
200            Err(_) => break,
201        }
202    }
203    String::from_utf8_lossy(&buf).into_owned()
204}
205
206#[cfg(test)]
207#[cfg(not(target_os = "windows"))]
208mod tests {
209    use super::*;
210    use crate::skills::{SkillMode, SkillScript};
211    use tempfile::TempDir;
212
213    fn make_skill_with_scripts(tmp: &TempDir, scripts: &[(&str, &str, &str)]) -> Vec<Skill> {
214        let skill_dir = tmp.path().join("test-skill");
215        std::fs::create_dir(&skill_dir).unwrap();
216        std::fs::write(
217            skill_dir.join("SKILL.md"),
218            "---\nname: test-skill\ndescription: Test\n---\n\nBody",
219        )
220        .unwrap();
221
222        let scripts_dir = skill_dir.join("scripts");
223        std::fs::create_dir(&scripts_dir).unwrap();
224
225        let mut skill_scripts = Vec::new();
226        for (name, content, desc) in scripts {
227            let path = scripts_dir.join(name);
228            std::fs::write(&path, content).unwrap();
229            // Make executable on Unix
230            #[cfg(unix)]
231            {
232                use std::os::unix::fs::PermissionsExt;
233                let perms = std::fs::metadata(&path).unwrap().permissions();
234                let mut new_perms = perms;
235                new_perms.set_mode(0o755);
236                std::fs::set_permissions(&path, new_perms).unwrap();
237            }
238            skill_scripts.push(SkillScript {
239                name: name.to_string(),
240                path,
241                description: desc.to_string(),
242            });
243        }
244
245        vec![Skill {
246            name: "test-skill".to_string(),
247            description: "Test".to_string(),
248            path: skill_dir.join("SKILL.md"),
249            refs: vec![],
250            params: vec![],
251            scripts: skill_scripts,
252            mode: SkillMode::Manual,
253            triggers: vec![],
254            hint: String::new(),
255            depends_on: vec![],
256            sections: vec![],
257        }]
258    }
259
260    #[tokio::test]
261    async fn run_skill_script_executes_and_returns_output() {
262        let tmp = TempDir::new().unwrap();
263        let skills = make_skill_with_scripts(
264            &tmp,
265            &[("hello.sh", "#!/bin/sh\necho 'Hello, World!'", "Say hello")],
266        );
267        let tool = RunSkillScript::new(skills, tmp.path().to_path_buf(), Duration::from_secs(30));
268
269        let result = tool
270            .execute(json!({"skill": "test-skill", "script": "hello.sh"}))
271            .await
272            .unwrap();
273
274        assert!(result.contains("exit: 0"));
275        assert!(result.contains("Hello, World!"));
276    }
277
278    #[tokio::test]
279    async fn run_skill_script_errors_on_unknown_skill() {
280        let tmp = TempDir::new().unwrap();
281        let skills = make_skill_with_scripts(&tmp, &[]);
282        let tool = RunSkillScript::new(skills, tmp.path().to_path_buf(), Duration::from_secs(30));
283
284        let result = tool
285            .execute(json!({"skill": "nonexistent", "script": "hello.sh"}))
286            .await;
287
288        assert!(result.is_err());
289        let err = result.unwrap_err().to_string();
290        assert!(err.contains("skill not found"));
291    }
292
293    #[tokio::test]
294    async fn run_skill_script_errors_on_unknown_script() {
295        let tmp = TempDir::new().unwrap();
296        let skills = make_skill_with_scripts(&tmp, &[("hello.sh", "#!/bin/sh\necho hi", "Say hi")]);
297        let tool = RunSkillScript::new(skills, tmp.path().to_path_buf(), Duration::from_secs(30));
298
299        let result = tool
300            .execute(json!({"skill": "test-skill", "script": "nonexistent"}))
301            .await;
302
303        assert!(result.is_err());
304        let err = result.unwrap_err().to_string();
305        assert!(err.contains("script not found"));
306        assert!(err.contains("hello.sh"));
307    }
308
309    #[tokio::test]
310    async fn run_skill_script_passes_args() {
311        let tmp = TempDir::new().unwrap();
312        let skills = make_skill_with_scripts(
313            &tmp,
314            &[("echo.sh", "#!/bin/sh\necho \"args: $*\"", "Echo args")],
315        );
316        let tool = RunSkillScript::new(skills, tmp.path().to_path_buf(), Duration::from_secs(30));
317
318        let result = tool
319            .execute(
320                json!({"skill": "test-skill", "script": "echo.sh", "args": "--verbose --debug"}),
321            )
322            .await
323            .unwrap();
324
325        assert!(result.contains("exit: 0"));
326        assert!(result.contains("args: --verbose --debug"));
327    }
328
329    #[tokio::test]
330    async fn run_skill_script_respects_timeout() {
331        let tmp = TempDir::new().unwrap();
332        let skills = make_skill_with_scripts(
333            &tmp,
334            &[("sleep.sh", "#!/bin/sh\nsleep 999", "Sleep forever")],
335        );
336        let tool =
337            RunSkillScript::new(skills, tmp.path().to_path_buf(), Duration::from_millis(100));
338
339        let result = tool
340            .execute(json!({"skill": "test-skill", "script": "sleep.sh"}))
341            .await;
342
343        assert!(result.is_err());
344        let err = result.unwrap_err().to_string();
345        assert!(err.contains("timed out"));
346    }
347
348    #[tokio::test]
349    async fn run_skill_script_case_insensitive() {
350        let tmp = TempDir::new().unwrap();
351        let skills =
352            make_skill_with_scripts(&tmp, &[("Hello.sh", "#!/bin/sh\necho 'hi'", "Say hi")]);
353        let tool = RunSkillScript::new(skills, tmp.path().to_path_buf(), Duration::from_secs(30));
354
355        // Different case for both skill and script
356        let result = tool
357            .execute(json!({"skill": "TEST-SKILL", "script": "HELLO.SH"}))
358            .await
359            .unwrap();
360
361        assert!(result.contains("exit: 0"));
362        assert!(result.contains("hi"));
363    }
364}